Coverage for src/lexigram/auth/models/session.py: 74%
38 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 00:58 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 00:58 +0800
1"""Session model for Lexigram Auth."""
3from __future__ import annotations
5from dataclasses import dataclass, field, replace
6from datetime import UTC, datetime, timedelta
7from typing import Any
10@dataclass(frozen=True)
11class UserSession:
12 """User session data model.
14 Tracks a specific device/session for a user.
15 """
17 session_id: str
18 user_id: str
19 device_id: str
20 ip_address: str | None = None
21 user_agent: str | None = None
22 geo_location: dict[str, Any] = field(default_factory=dict)
23 fingerprint: dict[str, Any] = field(default_factory=dict)
24 is_active: bool = True
25 expires_at: datetime | None = None
26 last_active_at: datetime | None = None
27 mfa_verified_at: datetime | None = None
28 created_at: datetime | None = None
29 updated_at: datetime | None = None
30 token_jti: str | None = None # JTI (JWT ID) of the associated access token
32 def is_expired(self) -> bool:
33 """Return ``True`` if this session has passed its expiry time."""
34 if not self.expires_at:
35 return False
37 # Ensure we compare aware datetimes
38 now = datetime.now(UTC) if self.expires_at.tzinfo else datetime.now()
39 return self.expires_at < now
41 def is_valid(self) -> bool:
42 """Return ``True`` if the session is active and has not expired.
44 A session is considered valid when :attr:`is_active` is ``True``
45 *and* :meth:`is_expired` returns ``False``.
46 """
47 return self.is_active and not self.is_expired()
49 def remaining_ttl(self) -> timedelta | None:
50 """Return the time remaining until this session expires.
52 Returns:
53 A :class:`~datetime.timedelta` representing time until expiry,
54 or ``None`` when no expiry is configured. Returns
55 ``timedelta(0)`` (zero duration) when the session has already
56 expired.
57 """
58 if self.expires_at is None:
59 return None
61 now = datetime.now(UTC) if self.expires_at.tzinfo else datetime.now()
62 delta = self.expires_at - now
63 return delta if delta.total_seconds() > 0 else timedelta(0)
65 def refresh(
66 self,
67 expires_at: datetime,
68 last_active_at: datetime | None = None,
69 ) -> UserSession:
70 """Return a new :class:`UserSession` with an updated expiry time.
72 Because :class:`UserSession` is a frozen dataclass, this method
73 returns a **new** instance rather than mutating the current one.
75 Args:
76 expires_at: The new expiry timestamp for the refreshed session.
77 last_active_at: Optional new last-active timestamp. Defaults
78 to ``datetime.now(UTC)`` when the session uses aware
79 datetimes, else ``datetime.now()``.
81 Returns:
82 A copy of this session with :attr:`expires_at` (and
83 :attr:`last_active_at`) updated.
84 """
85 if last_active_at is None:
86 last_active_at = datetime.now(UTC) if expires_at.tzinfo else datetime.now()
87 return replace(self, expires_at=expires_at, last_active_at=last_active_at)
90__all__ = [
91 "UserSession",
92]