Files
Macky 3c22d88bcd feat: demo SaaS + training flow security hardening (8/8 review gate passed)
- Demo accounts: super_admin-only provisioning into isolated DEMO_ORG_ID tenant,
  30-day UTC trial on first login, revocable, one-time credential delivery via
  optional SES/webhook (never persisted). Adds boto3 dependency.
- Analytics/report/export/privacy: shared bounded scan budget across users/groups/
  sessions, tenant-consistent session/user/group joins, scalar-only CSV export
  (no nested persisted-value stringification).
- Ownership/tenant isolation: canonical owner-tenant predicate for list/read/chat;
  client sees is_owned only, never owner_user_id.
- Lifecycle/races: status transition validation, analyzing is an in-progress gate
  (no duplicate reanalysis), structured-ready publication, stale-variant revalidation.
- Auth/setup/consent/JWT/OAuth/config: fail-closed consent, bounded JWT lifetime,
  provider-subject atomic OAuth identity, repeated-secret rejection, strict Persona
  trait validation.
- Chat/session/privacy: pre-seller opener redaction, corrupt-session recovery,
  role-aware completed-chat dashboard routing.
- Frontend: Training→product→personas→practice flow, demo/role/demo guards,
  is_owned-based ownership display, 320×568 and 500×768 responsive E2E.
- 8 independent exact-five-key review scopes passed; backend 509, frontend 26,
  production build 1775 modules, isolated E2E 15.
2026-08-25 06:39:06 +07:00

864 lines
35 KiB
Python

"""User + organization store and auth logic (JWT, password hashing, roles).
Login identity is the user's `username` (stable id). `email` is an optional
separate field that admins/users can set; the default admin must set an email
+before first real use (enforced via `must_setup`).
"""
from __future__ import annotations
import datetime
import math
import re
from contextlib import nullcontext
from pathlib import Path
from typing import Any, TypeGuard
import jwt
from werkzeug.security import check_password_hash, generate_password_hash
from ..config import Config
from ..storage.store import (
JsonStore,
StoreError,
contains_control_characters,
new_id,
)
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
USERNAME_RE = re.compile(r"^[a-zA-Z0-9_.-]{2,64}$")
MAX_LOGIN_IDENTIFIER_LENGTH = 254
_UNSET = object()
PUBLIC_USER_FIELDS = (
"id",
"username",
"email",
"org_id",
"org_name",
"name",
"role",
"must_setup",
"created_at",
"active",
"accepted_terms",
"accepted_terms_at",
"is_demo",
"trial_started_at",
"trial_expires_at",
)
def utc_now() -> datetime.datetime:
return datetime.datetime.now(datetime.timezone.utc)
def is_valid_terms_timestamp(value: object) -> bool:
"""Require a timezone-aware ISO timestamp for accepted consent."""
if not isinstance(value, str) or not value.strip():
return False
try:
parsed = datetime.datetime.fromisoformat(value)
except (TypeError, ValueError):
return False
return parsed.tzinfo is not None and parsed.utcoffset() is not None
def _parse_utc_timestamp(value: object) -> datetime.datetime | None:
"""Parse persisted ISO timestamps without accepting naive/invalid values."""
if not isinstance(value, str) or not value.strip():
return None
try:
parsed = datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
except (TypeError, ValueError):
return None
if parsed.tzinfo is None:
return None
return parsed.astimezone(datetime.timezone.utc)
def is_demo_account(user: dict[str, Any]) -> bool:
"""Treat the role as authoritative for legacy records missing demo fields."""
return isinstance(user, dict) and user.get("role") == "demo"
def _demo_trial_state(
user: dict[str, Any],
) -> tuple[datetime.datetime | None, datetime.datetime | None, bool]:
"""Return ``(started, expires, valid)`` for a persisted demo trial.
Before first login both timestamps are absent. Once either timestamp is
present, both must be timezone-aware, ordered, and exactly the fixed trial
duration. Invalid legacy state fails closed instead of granting access.
"""
if not is_demo_account(user):
return None, None, True
started_raw = user.get("trial_started_at")
expires_raw = user.get("trial_expires_at")
started_present = started_raw not in (None, "")
expires_present = expires_raw not in (None, "")
if not started_present and not expires_present:
return None, None, True
if started_present != expires_present:
return None, None, False
started = _parse_utc_timestamp(started_raw)
expires = _parse_utc_timestamp(expires_raw)
if started is None or expires is None:
return None, None, False
if expires <= started or expires - started != datetime.timedelta(days=Config.DEMO_TRIAL_DAYS):
return None, None, False
return started, expires, True
def demo_trial_is_active(user: dict[str, Any], *, now: datetime.datetime | None = None) -> bool:
"""Return whether a demo account is inside its fixed UTC trial window."""
if not is_demo_account(user):
return True
_started, expires, valid = _demo_trial_state(user)
if not valid:
return False
if expires is None:
# A demo account before its first successful login has no expiry yet.
return True
current = now or utc_now()
if current.tzinfo is None:
current = current.replace(tzinfo=datetime.timezone.utc)
current = current.astimezone(datetime.timezone.utc)
return current < expires
def demo_trial_expiry(user: dict[str, Any]) -> datetime.datetime | None:
if not is_demo_account(user):
return None
_started, expires, valid = _demo_trial_state(user)
return expires if valid else None
class AuthError(Exception):
pass
class SetupAlreadyCompletedError(AuthError):
"""Raised when setup is attempted after the one-time transition."""
pass
def is_valid_tenant_id(value: object) -> TypeGuard[str]:
"""Accept only canonical, nonblank tenant identifiers."""
if (
not isinstance(value, str)
or not value
or len(value) > 200
or value != value.strip()
or "/" in value
or "\\" in value
or value == "."
or ".." in value
or contains_control_characters(value)
):
return False
try:
if len(value.encode("utf-8")) > 200:
return False
except UnicodeError:
return False
return True
def normalize_identifier(value: object) -> str:
"""Normalize a login/storage identifier without allowing unsafe path input."""
if not isinstance(value, str):
raise AuthError("invalid identifier")
normalized = value.strip().lower()
if (
not normalized
or len(normalized) > MAX_LOGIN_IDENTIFIER_LENGTH
or "/" in normalized
or "\\" in normalized
or ".." in normalized
or contains_control_characters(normalized)
):
raise AuthError("invalid identifier")
try:
if len(normalized.encode("utf-8")) > 200:
raise AuthError("invalid identifier")
except UnicodeError as exc:
raise AuthError("invalid identifier") from exc
return normalized
class UserStore:
def __init__(self, data_dir: Path) -> None:
self.users = JsonStore(data_dir / "users")
self.orgs = JsonStore(data_dir / "orgs")
# ── org ────────────────────────────────────────────────────────────
def create_org(
self,
name: str,
*,
org_id: str | None = None,
seats: int = 5,
plan: str = "trial",
) -> dict[str, Any]:
oid = new_id("org") if org_id is None else org_id
if not is_valid_tenant_id(oid):
raise AuthError("invalid organization id")
if isinstance(seats, bool) or not isinstance(seats, int) or seats < 1:
raise AuthError("invalid organization seats")
if plan not in ("trial", "paid", "enterprise"):
raise AuthError("invalid organization plan")
return self.orgs.create(
{
"name": name,
"id": oid,
"plan": plan,
"seats": seats,
"active": True,
"created_at": __import__("time").strftime("%Y-%m-%dT%H:%M:%SZ"),
},
key=oid,
)
def get_org(self, org_id: str) -> dict[str, Any]:
return self.orgs.get(org_id)
def get_org_or_none(self, org_id: str | None) -> dict[str, Any] | None:
"""Return an organization without turning a missing tenant into a default."""
if not is_valid_tenant_id(org_id):
return None
return self.orgs.get_or_none(org_id)
def ensure_demo_org(self) -> dict[str, Any]:
"""Create the configured demo tenant once, then fail closed if inactive."""
org_id = Config.DEMO_ORG_ID
if not is_valid_tenant_id(org_id):
raise AuthError("demo organization is invalid")
with self.orgs.record_lock(org_id):
org = self.orgs.get_or_none(org_id)
if org is None:
org = self.create_org(
Config.DEMO_ORG_NAME,
org_id=org_id,
seats=Config.DEMO_ORG_SEATS,
plan="trial",
)
if org.get("active") is not True:
raise AuthError("demo organization is inactive")
return org
# ── users ──────────────────────────────────────────────────────────
@staticmethod
def _norm(username: str) -> str:
try:
return normalize_identifier(username)
except AuthError as exc:
raise AuthError("invalid username") from exc
@staticmethod
def validate_password(password: object) -> str:
"""Validate credentials before any caller performs a mutation."""
if not isinstance(password, str) or not password:
raise AuthError("password is required")
if len(password) < Config.MIN_PASSWORD_LENGTH:
raise AuthError(
f"password must be at least {Config.MIN_PASSWORD_LENGTH} characters"
)
return password
@staticmethod
def _next_auth_version(user: dict[str, Any]) -> int:
"""Return the next session version for password-bearing mutations."""
return UserStore._auth_version(user) + 1
@staticmethod
def _auth_version(user: dict[str, Any]) -> int:
if "auth_version" not in user:
return 0 # Legacy records without the field use the initial version.
current = user.get("auth_version")
if isinstance(current, bool) or not isinstance(current, int) or current < 0:
raise AuthError("invalid account state")
return current
@staticmethod
def _password_matches(password_hash: object, password: str) -> bool:
"""Treat malformed persisted hashes as invalid credentials, never 500."""
if not isinstance(password_hash, str) or not password_hash:
return False
try:
return check_password_hash(password_hash, password)
except (TypeError, ValueError):
return False
@staticmethod
def _token_identity(user: dict[str, Any]) -> tuple[str, str, str]:
username = user.get("username")
user_id = user.get("id")
org_id = user.get("org_id")
role = user.get("role")
try:
normalized_username = normalize_identifier(username)
normalized_user_id = normalize_identifier(user_id)
except AuthError as exc:
raise AuthError("invalid account state") from exc
if (
not isinstance(username, str)
or not username.strip()
or USERNAME_RE.fullmatch(username) is None
or normalized_username != username
or not isinstance(user_id, str)
or not user_id.strip()
or normalized_user_id != user_id
or user_id != username
or not is_valid_tenant_id(org_id)
or not isinstance(role, str)
or role not in Config.ROLES
or (role == "demo" and org_id != Config.DEMO_ORG_ID)
):
raise AuthError("invalid account state")
persisted_email = user.get("email")
if persisted_email is not None:
try:
canonical_email = UserStore.validate_email(
persisted_email, allow_empty=False
)
except AuthError as exc:
raise AuthError("invalid account state") from exc
if canonical_email != persisted_email:
raise AuthError("invalid account state")
return username, org_id, role
def validate_persisted_identity(self, user: dict[str, Any]) -> None:
"""Fail closed when an existing account record loses canonical identity."""
self._token_identity(user)
@staticmethod
def validate_email(email: object, *, allow_empty: bool = True) -> str | None:
"""Normalize an email and reject malformed/non-string values safely."""
if email is None or (isinstance(email, str) and not email.strip()):
if allow_empty:
return None
raise AuthError("invalid email")
if not isinstance(email, str):
raise AuthError("invalid email")
try:
normalized = normalize_identifier(email)
except AuthError as exc:
raise AuthError("invalid email") from exc
if not EMAIL_RE.fullmatch(normalized):
raise AuthError("invalid email")
return normalized
def create_user(
self,
*,
org_id: str,
username: str,
password: str,
name: str,
role: str = "user",
email: str | None = None,
must_setup: bool = False,
allow_demo: bool = False,
accepted_terms: bool = False,
oauth_identity: tuple[str, str] | None = None,
_users_lock_held: bool = False,
) -> dict[str, Any]:
if not is_valid_tenant_id(org_id):
raise AuthError("invalid organization id")
if not isinstance(role, str) or role not in Config.ROLES:
raise AuthError(f"invalid role: {role}")
if role == "demo":
if not allow_demo:
raise AuthError("demo accounts require dedicated provisioning")
if org_id != Config.DEMO_ORG_ID:
raise AuthError("demo accounts must use the demo organization")
if not isinstance(must_setup, bool):
raise AuthError("must_setup must be boolean")
if not isinstance(accepted_terms, bool):
raise AuthError("accepted_terms must be boolean")
username = self._norm(username)
if not username or not password:
raise AuthError("username and password are required")
password = self.validate_password(password)
if USERNAME_RE.fullmatch(username) is None:
raise AuthError("invalid username (letters/numbers/._- only, 2-64 chars)")
if not isinstance(name, str):
raise AuthError("name must be a string")
email = self.validate_email(email)
if oauth_identity is not None:
if (
not isinstance(oauth_identity, tuple)
or len(oauth_identity) != 2
or oauth_identity[0] not in ("google", "facebook")
or not isinstance(oauth_identity[1], str)
or not oauth_identity[1].strip()
or len(oauth_identity[1]) > 255
):
raise AuthError("invalid oauth identity")
oauth_identity = (oauth_identity[0], oauth_identity[1].strip())
# Username/email uniqueness and seat capacity are collection-wide
# invariants. Keep their checks with the create so setup/profile writes
# cannot reserve the same email concurrently in another worker.
# Seat capacity and org activation must share a lock with org updates;
# otherwise an admin can deactivate/shrink a tenant between this check
# and the user write.
try:
# Keep the lock order users -> organization for every account
# creation path. Registration relies on this outer collection lock
# to make the first-user role decision atomic with the write.
users_lock = nullcontext() if _users_lock_held else self.users.collection_lock()
with users_lock:
with self.orgs.record_lock(org_id):
org = self.orgs.get_or_none(org_id)
if org is None:
raise AuthError("organization not found")
if org.get("active") is not True:
raise AuthError("organization is inactive")
raw_seats = org.get("seats", 5)
if type(raw_seats) is not int or raw_seats < 0:
raise AuthError("invalid organization state")
seats = raw_seats
if seats <= 0:
raise AuthError("organization has no available seats")
existing = [u for u in self.users.all() if u.get("org_id") == org_id]
if len(existing) >= seats:
raise AuthError("organization seat limit reached")
if self.users.get_or_none(username) is not None:
raise AuthError("a user with this username already exists")
if email and self.email_exists(email):
raise AuthError("a user with this email already exists")
user = {
"id": username,
"username": username,
"email": email,
"org_id": org_id,
"org_name": org.get("name", ""),
"name": name.strip() or username,
"password_hash": generate_password_hash(password),
"role": role,
"must_setup": must_setup,
"accepted_terms": accepted_terms,
"accepted_terms_at": utc_now().isoformat() if accepted_terms else None,
"auth_version": 0,
"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"active": True,
}
if oauth_identity is not None:
user["oauth_identities"] = [
{"provider": oauth_identity[0], "subject": oauth_identity[1]}
]
if role == "demo":
user.update(
is_demo=True,
trial_started_at=None,
trial_expires_at=None,
)
return self.users.create(user, key=username)
except AuthError:
raise
except (OSError, StoreError, TypeError, UnicodeError, ValueError, OverflowError) as exc:
raise AuthError("account storage unavailable") from exc
def create_demo_user(
self,
*,
email: str,
username: str,
password: str,
name: str = "Demo User",
) -> dict[str, Any]:
"""Create the only supported kind of demo user."""
return self.create_user(
org_id=Config.DEMO_ORG_ID,
username=username,
password=password,
name=name,
role="demo",
email=email,
must_setup=True,
accepted_terms=False,
allow_demo=True,
)
def get_user(self, username: str) -> dict[str, Any]:
return self.users.get(self._norm(username))
def get_user_or_none(self, username: str) -> dict[str, Any] | None:
return self.users.get_or_none(self._norm(username))
def by_email(self, email: object) -> dict[str, Any] | None:
email = self.validate_email(email)
if email is None:
return None
for u in self.users.all():
stored_email = u.get("email")
if stored_email is None:
continue
try:
canonical_email = self.validate_email(stored_email, allow_empty=False)
except AuthError as exc:
raise AuthError("invalid account state") from exc
if canonical_email != stored_email:
raise AuthError("invalid account state")
if canonical_email == email:
return u
return None
def email_exists(self, email: str) -> bool:
return self.by_email(email) is not None
def list_users(self, *, org_id: str | None = None) -> list[dict[str, Any]]:
if org_id is not None and not is_valid_tenant_id(org_id):
return []
users = self.users.all()
if org_id is None:
return [self.public_user(user) for user in users]
users = [u for u in users if u.get("org_id") == org_id]
return [self.public_user(user) for user in users]
def set_active(self, username: str, active: bool) -> dict[str, Any]:
return self.update_user_fields(username, active=active)
def set_role(self, username: str, role: str) -> dict[str, Any]:
return self.update_user_fields(username, role=role)
def set_password(self, username: str, new_password: str) -> dict[str, Any]:
return self.update_user_fields(username, password=new_password)
def change_password(
self,
username: str,
current_password: object,
new_password: object,
) -> dict[str, Any]:
"""Change one user's password after verifying the current password."""
username = self._norm(username)
if not isinstance(current_password, str) or not current_password:
raise AuthError("current password is invalid")
new_password = self.validate_password(new_password)
with self.users.record_lock(username):
current = self.users.get_or_none(username)
if current is None or current.get("active") is not True:
raise AuthError("current password is invalid")
if not self._password_matches(current.get("password_hash"), current_password):
raise AuthError("current password is invalid")
return self.users.update(
username,
password_hash=generate_password_hash(new_password),
auth_version=self._next_auth_version(current),
)
def set_email(self, username: str, email: str) -> dict[str, Any]:
return self.update_user_fields(username, email=email)
def set_name(self, username: str, name: str) -> dict[str, Any]:
return self.update_user_fields(username, name=name)
def update_user_fields(
self,
username: str,
*,
name: object = _UNSET,
email: object = _UNSET,
password: object = _UNSET,
role: object = _UNSET,
active: object = _UNSET,
) -> dict[str, Any]:
"""Validate and apply related user fields as one locked mutation."""
username = self._norm(username)
fields: dict[str, Any] = {}
if name is not _UNSET:
if not isinstance(name, str) or not name.strip():
raise AuthError("name is required")
fields["name"] = name.strip()
normalized_email: str | None = None
if email is not _UNSET:
normalized_email = self.validate_email(email, allow_empty=False)
if normalized_email is None: # defensive: required email validation above
raise AuthError("invalid email")
fields["email"] = normalized_email
if password is not _UNSET:
password = self.validate_password(password)
fields["password_hash"] = generate_password_hash(password)
if role is not _UNSET:
if not isinstance(role, str) or role not in Config.ROLES:
raise AuthError("invalid role")
if role == "demo":
raise AuthError("demo accounts require dedicated provisioning")
fields["role"] = role
if active is not _UNSET:
if not isinstance(active, bool):
raise AuthError("active must be a boolean")
fields["active"] = active
with self.users.collection_lock():
with self.users.record_lock(username):
current = self.users.get_or_none(username)
if current is None:
raise AuthError("user not found")
if current.get("role") == "demo" and (
role is not _UNSET or active is not _UNSET
):
raise AuthError("manage demo accounts through the dedicated endpoint")
if normalized_email is not None:
existing = self.by_email(normalized_email)
if existing and existing["id"] != username:
raise AuthError("a user with this email already exists")
if password is not _UNSET or role is not _UNSET or active is not _UNSET:
fields["auth_version"] = self._next_auth_version(current)
if not fields:
return current
return self.users.update(username, **fields)
def complete_setup(
self,
username: str,
email: str,
new_password: str,
*,
accepted_terms: object,
accepted_terms_at: str | None = None,
server_generated_at: str | None = None,
) -> dict[str, Any]:
"""Complete setup for a user that is still marked ``must_setup``.
Keep the setup fields in one store update so a caller cannot leave an
account half-configured after changing only its email or password.
"""
username = self._norm(username)
if accepted_terms is not True:
raise AuthError("terms acceptance is required")
new_password = self.validate_password(new_password)
email = self.validate_email(email, allow_empty=False)
if email is None: # defensive: required email validation above
raise AuthError("invalid email")
password_hash = generate_password_hash(new_password)
consent_timestamp = (
server_generated_at
if is_valid_terms_timestamp(server_generated_at)
else utc_now().isoformat()
)
with self.users.collection_lock():
with self.users.record_lock(username):
current = self.users.get_or_none(username)
if current is None:
raise AuthError("user not found")
if current.get("must_setup") is False and current.get("accepted_terms") is True:
raise SetupAlreadyCompletedError("setup already completed")
existing = self.by_email(email)
if existing and existing["id"] != username:
raise AuthError("a user with this email already exists")
updated = self.users.update_if(
username,
lambda latest: (
latest.get("must_setup") is not False
or latest.get("accepted_terms") is not True
),
email=email,
password_hash=password_hash,
must_setup=False,
accepted_terms=True,
accepted_terms_at=consent_timestamp,
auth_version=self._next_auth_version(current),
)
if updated is None:
raise SetupAlreadyCompletedError("setup already completed")
return updated
def start_demo_trial(
self,
username: str,
*,
now: datetime.datetime | None = None,
) -> dict[str, Any]:
"""Atomically start a demo trial on the first successful login only."""
username = self._norm(username)
current_time = (now or utc_now()).astimezone(datetime.timezone.utc)
with self.users.record_lock(username):
current = self.users.get_or_none(username)
if (
current is None
or not is_demo_account(current)
or current.get("org_id") != Config.DEMO_ORG_ID
or current.get("active") is not True
):
raise AuthError("demo account not found")
started, expires, valid = _demo_trial_state(current)
if not valid:
raise AuthError("invalid demo trial state")
if expires is not None:
if current_time >= expires:
raise AuthError("demo trial expired")
return current
if started is not None:
# A started trial without an expiry is malformed and must not be
# silently repaired into a longer trial.
raise AuthError("invalid demo trial state")
expiry = current_time + datetime.timedelta(days=Config.DEMO_TRIAL_DAYS)
return self.users.update(
username,
is_demo=True,
trial_started_at=current_time.isoformat(),
trial_expires_at=expiry.isoformat(),
)
def revoke_demo(self, username: str) -> dict[str, Any]:
"""Deactivate a demo account and invalidate every issued auth token."""
username = self._norm(username)
with self.users.record_lock(username):
current = self.users.get_or_none(username)
if (
current is None
or not is_demo_account(current)
or current.get("org_id") != Config.DEMO_ORG_ID
):
raise AuthError("demo account not found")
return self.users.update(
username,
active=False,
auth_version=self._next_auth_version(current),
)
# ── auth ───────────────────────────────────────────────────────────
def verify(self, ident: str, password: str) -> dict[str, Any]:
# Resolve by EITHER username OR email (the login form doesn't distinguish,
# and users naturally type their email after setup). Fall back to username.
if not isinstance(ident, str) or not isinstance(password, str):
raise AuthError("invalid credentials")
try:
normalized_ident = normalize_identifier(ident)
except AuthError as exc:
raise AuthError("invalid credentials") from exc
try:
user = self.get_user_or_none(normalized_ident) or self.by_email(normalized_ident)
except (AuthError, OSError, StoreError, TypeError, UnicodeError, ValueError) as exc:
raise AuthError("invalid credentials") from exc
if not user or user.get("active") is not True:
raise AuthError("invalid credentials")
if not isinstance(user.get("must_setup"), bool):
raise AuthError("invalid credentials")
if user.get("accepted_terms") is True and not is_valid_terms_timestamp(
user.get("accepted_terms_at")
):
raise AuthError("invalid credentials")
# SaaS gate: orphaned or inactive organizations cannot sign in.
if not isinstance(user.get("role"), str) or user.get("role") not in Config.ROLES:
raise AuthError("invalid credentials")
oid = user.get("org_id")
try:
org = self.get_org_or_none(oid)
except (OSError, StoreError, TypeError, UnicodeError, ValueError) as exc:
raise AuthError("invalid credentials") from exc
if not org or org.get("active") is not True:
raise AuthError("invalid credentials")
try:
self._auth_version(user)
except AuthError as exc:
raise AuthError("invalid credentials") from exc
try:
self._token_identity(user)
except AuthError as exc:
raise AuthError("invalid credentials") from exc
if is_demo_account(user):
_started, expires, valid = _demo_trial_state(user)
if not valid:
raise AuthError("invalid credentials")
if expires is not None and utc_now() >= expires:
raise AuthError("invalid credentials")
if not self._password_matches(user.get("password_hash"), password):
raise AuthError("invalid credentials")
return user
def issue_token(self, user: dict[str, Any]) -> str:
now = utc_now()
subject, org_id, role = self._token_identity(user)
if user.get("active") is not True:
raise AuthError("invalid account state")
expires_hours = Config.JWT_EXPIRES_HOURS
if (
isinstance(expires_hours, bool)
or not isinstance(expires_hours, int)
or expires_hours < Config.JWT_MIN_EXPIRES_HOURS
or expires_hours > Config.JWT_MAX_EXPIRES_HOURS
):
raise AuthError("invalid account state")
expires_at = now + datetime.timedelta(hours=expires_hours)
if is_demo_account(user):
trial_expires = demo_trial_expiry(user)
if trial_expires is None or now >= trial_expires:
raise AuthError("demo trial expired")
# A demo JWT may never outlive the fixed UTC trial window.
expires_at = min(expires_at, trial_expires)
if expires_at <= now:
raise AuthError("invalid account state")
payload = {
"sub": subject,
"org_id": org_id,
"role": role,
"auth_version": self._auth_version(user),
"iat": now,
"exp": expires_at,
}
return jwt.encode(payload, Config.SECRET_KEY, algorithm=Config.JWT_ALGO)
def decode_token(self, token: str) -> dict[str, Any]:
try:
payload = jwt.decode(
token,
Config.SECRET_KEY,
algorithms=[Config.JWT_ALGO],
options={"require": ["sub", "org_id", "role", "auth_version", "iat", "exp"]},
)
if not isinstance(payload, dict):
raise AuthError("invalid or expired token")
subject = payload.get("sub")
try:
normalized_subject = normalize_identifier(subject)
except AuthError as exc:
raise AuthError("invalid or expired token") from exc
if (
not isinstance(subject, str)
or normalized_subject != subject
or USERNAME_RE.fullmatch(subject) is None
or not is_valid_tenant_id(payload.get("org_id"))
or not isinstance(payload.get("role"), str)
or payload["role"] not in Config.ROLES
or isinstance(payload.get("auth_version"), bool)
or not isinstance(payload.get("auth_version"), int)
or payload["auth_version"] < 0
or isinstance(payload.get("iat"), bool)
or not isinstance(payload.get("iat"), (int, float))
or not math.isfinite(payload["iat"])
or isinstance(payload.get("exp"), bool)
or not isinstance(payload.get("exp"), (int, float))
or not math.isfinite(payload["exp"])
or payload["exp"] <= 0
or payload["iat"] <= 0
or payload["iat"] >= payload["exp"]
or payload["exp"] - payload["iat"]
> Config.JWT_EXPIRES_HOURS * 60 * 60
):
raise AuthError("invalid or expired token")
return payload
except AuthError:
raise
except (jwt.PyJWTError, OverflowError, TypeError, ValueError) as exc:
raise AuthError("invalid or expired token") from exc
def public_user(self, user: dict[str, Any]) -> dict[str, Any]:
public = {
field: user[field]
for field in PUBLIC_USER_FIELDS
if field in user
}
# Legacy records may omit either consent/setup marker. Normalize those
# omissions to the safe public state so clients also redirect to setup.
public["must_setup"] = user.get("must_setup") is not False
public["accepted_terms"] = user.get("accepted_terms") is True
if is_demo_account(user):
# Legacy demo records may predate these fields; expose the role-safe
# state without ever exposing a password or a recovery mechanism.
public["is_demo"] = True
public.setdefault("trial_started_at", user.get("trial_started_at"))
public.setdefault("trial_expires_at", user.get("trial_expires_at"))
return public