Coverage for src/lexigram/auth/session/manager.py: 84%

98 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-26 00:58 +0800

1"""Session Management for Lexigram Auth.""" 

2 

3from __future__ import annotations 

4 

5from collections.abc import Awaitable, Callable 

6from datetime import datetime, timedelta 

7from typing import Any 

8 

9from lexigram.auth.exceptions import ( 

10 AuthenticationError, 

11 SessionNotFoundError, 

12 TokenExpiredError, 

13) 

14from lexigram.auth.models.session import UserSession 

15from lexigram.auth.session.fingerprint import generate_device_id 

16from lexigram.auth.storage.in_memory_stores import InMemorySessionStore 

17from lexigram.auth.storage.session_store import SessionStore 

18from lexigram.contracts.audit import AuditEntry, AuditLoggerProtocol 

19from lexigram.contracts.core.identity import IdGeneratorProtocol 

20from lexigram.di.decorators import inject 

21from lexigram.logging import get_logger 

22from lexigram.primitives import clock as ambient_clock 

23from lexigram.result import Err, Ok, Result 

24 

25logger = get_logger(__name__) 

26 

27# Minimum seconds between last_active_at DB writes for the same session. 

28# Prevents an excessive write on every authenticated request under load. 

29_ACTIVITY_DEBOUNCE_SECONDS: float = 60.0 

30 

31 

32@inject 

33class SessionManagerImpl: 

34 """Manages persistent user sessions with device awareness. 

35 

36 Uses SessionStore protocol for storage abstraction, allowing different 

37 backends (SQL, Redis, in-memory). 

38 """ 

39 

40 DEFAULT_EXPIRY_DAYS = 30 

41 

42 def __init__( 

43 self, 

44 session_store: SessionStore | None = None, 

45 ids: IdGeneratorProtocol | None = None, 

46 *, 

47 audit_logger: AuditLoggerProtocol | None = None, 

48 revoke_token: Callable[[str], Awaitable[None]] | None = None, 

49 max_sessions_per_user: int | None = None, 

50 ) -> None: 

51 """Initialise the session manager. 

52 

53 Args: 

54 session_store: Optional storage backend. Defaults to an 

55 :class:`~lexigram.auth.storage.in_memory_stores.InMemorySessionStore` 

56 when not provided. 

57 audit_logger: Optional :class:`~lexigram.contracts.audit.AuditLoggerProtocol` 

58 used to record security-sensitive session events (create / 

59 revoke). No audit entries are written when *None*. 

60 revoke_token: Optional async callable that accepts a JTI string and 

61 adds it to the token blacklist. When provided, revoking a 

62 session also invalidates its associated JWT access token. 

63 max_sessions_per_user: Maximum concurrent sessions per user before 

64 the least-recently-used session is evicted. ``None`` (the 

65 default) means no limit is enforced. 

66 """ 

67 self._store: SessionStore = session_store or InMemorySessionStore() 

68 self._ids = ids 

69 self._audit_logger: AuditLoggerProtocol | None = audit_logger 

70 self._revoke_token: Callable[[str], Awaitable[None]] | None = revoke_token 

71 self._max_sessions_per_user: int | None = max_sessions_per_user 

72 # Tracks the last monotonic timestamp at which last_active_at was written 

73 # for each session, so that we can debounce the per-request DB write. 

74 self._last_activity_write: dict[str, float] = {} 

75 

76 def __repr__(self) -> str: 

77 """Return developer-friendly string representation.""" 

78 return f"SessionManagerImpl(store={self._store!r})" 

79 

80 async def create_session( 

81 self, 

82 user_id: str, 

83 fingerprint_data: dict[str, Any], 

84 ip_address: str | None = None, 

85 user_agent: str | None = None, 

86 expires_days: int = DEFAULT_EXPIRY_DAYS, 

87 ) -> UserSession: 

88 """Create a new session for a user.""" 

89 device_id = generate_device_id(fingerprint_data) 

90 

91 active_sessions = await self.get_active_sessions(user_id) 

92 if ( 

93 self._max_sessions_per_user is not None 

94 and len(active_sessions) >= self._max_sessions_per_user 

95 ): 

96 sorted_sessions = sorted( 

97 active_sessions, 

98 key=lambda s: s.last_active_at or s.created_at or datetime.min, 

99 ) 

100 await self.revoke_session(sorted_sessions[0].session_id) 

101 logger.info("Revoked oldest session for user %s due to limit", user_id) 

102 

103 session_id = ( 

104 self._ids.generate_for("Session") 

105 if self._ids 

106 else self._generate_fallback_id() 

107 ) 

108 now = ambient_clock.now() 

109 expires_at = now + timedelta(days=expires_days) 

110 

111 session = UserSession( 

112 session_id=session_id, 

113 user_id=user_id, 

114 device_id=device_id, 

115 ip_address=ip_address, 

116 user_agent=user_agent, 

117 fingerprint=fingerprint_data, 

118 expires_at=expires_at, 

119 created_at=now, 

120 last_active_at=now, 

121 ) 

122 

123 await self._store.create(session) 

124 

125 logger.info( 

126 "Created session %s for user %s (Device: %s)", 

127 session_id, 

128 user_id, 

129 device_id, 

130 ) 

131 

132 if self._audit_logger is not None: 

133 await self._audit_logger.log( 

134 AuditEntry( 

135 action="session.created", 

136 actor_id=user_id, 

137 resource_type="Session", 

138 resource_id=session_id, 

139 outcome="success", 

140 metadata={ 

141 "device_id": device_id, 

142 "ip_address": ip_address, 

143 "user_agent": user_agent, 

144 }, 

145 ) 

146 ) 

147 

148 return session 

149 

150 async def validate_session( 

151 self, session_id: str 

152 ) -> Result[ 

153 UserSession, AuthenticationError | SessionNotFoundError | TokenExpiredError 

154 ]: 

155 """Validate a session and update its activity. 

156 

157 Args: 

158 session_id: The session identifier to validate. 

159 

160 Returns: 

161 Ok(session) if the session is valid and active. 

162 Err(SessionNotFoundError) if the session does not exist. 

163 Err(TokenExpiredError) if the session has expired (it is also revoked). 

164 Err(AuthenticationError) if the session exists but is inactive. 

165 """ 

166 session = await self._store.get(session_id) 

167 

168 if not session: 

169 return Err(SessionNotFoundError(session_id)) 

170 

171 if session.is_expired(): 

172 await self.revoke_session(session_id) 

173 logger.warning("Session %s expired", session_id) 

174 return Err(TokenExpiredError("Session has expired")) 

175 

176 if not session.is_active: 

177 return Err(AuthenticationError("Session is inactive")) 

178 

179 now = ambient_clock.monotonic() 

180 last_write = self._last_activity_write.get(session_id, 0.0) 

181 if now - last_write >= _ACTIVITY_DEBOUNCE_SECONDS: 

182 wall_now = ambient_clock.now() 

183 await self._store.update( 

184 session_id, 

185 last_active_at=wall_now, 

186 ) 

187 self._last_activity_write[session_id] = now 

188 self._prune_activity_write_cache(now) 

189 

190 return Ok(session) 

191 

192 def _prune_activity_write_cache(self, now: float) -> None: 

193 """Remove stale entries from the activity-write tracker. 

194 

195 Entries older than 2x the debounce window can never influence a future 

196 debounce decision, so they are safe to discard. Pruning on every write 

197 (rather than on a timer) keeps memory bounded without background threads. 

198 """ 

199 cutoff = now - (_ACTIVITY_DEBOUNCE_SECONDS * 2) 

200 stale = [sid for sid, ts in self._last_activity_write.items() if ts < cutoff] 

201 for sid in stale: 

202 del self._last_activity_write[sid] 

203 

204 async def revoke_session(self, session_id: str) -> bool: 

205 """Revoke a specific session. 

206 

207 If a ``revoke_token`` callback was provided at construction time and 

208 the session has an associated ``token_jti``, the JWT is added to the 

209 token blacklist so it cannot be reused until natural expiry. 

210 """ 

211 # Fetch the session before revoking so we can include user_id in audit log 

212 # and blacklist its associated JWT if a token-revocation callback is set. 

213 needs_fetch = self._audit_logger is not None or self._revoke_token is not None 

214 session = await self._store.get(session_id) if needs_fetch else None 

215 

216 await self._store.update( 

217 session_id, 

218 is_active=False, 

219 updated_at=ambient_clock.now(), 

220 ) 

221 logger.info("Revoked session: %s", session_id) 

222 

223 # Blacklist the associated JWT access token if we have a JTI and callback 

224 if session and session.token_jti and self._revoke_token is not None: 

225 try: 

226 await self._revoke_token(session.token_jti) 

227 logger.debug( 

228 "Blacklisted JWT jti=%s on session revocation", session.token_jti 

229 ) 

230 except (RuntimeError, OSError, ConnectionError, ValueError) as e: 

231 logger.warning( 

232 "Failed to blacklist token jti=%s during session revocation: %s", 

233 session.token_jti, 

234 e, 

235 ) 

236 

237 if self._audit_logger is not None: 

238 actor = session.user_id if session else session_id 

239 await self._audit_logger.log( 

240 AuditEntry( 

241 action="session.revoked", 

242 actor_id=actor, 

243 resource_type="Session", 

244 resource_id=session_id, 

245 outcome="success", 

246 ) 

247 ) 

248 

249 return True 

250 

251 async def verify_mfa(self, session_id: str) -> bool: 

252 """Mark the session as MFA verified.""" 

253 now = ambient_clock.now() 

254 await self._store.update( 

255 session_id, 

256 mfa_verified_at=now, 

257 updated_at=now, 

258 ) 

259 logger.info("MFA verified for session: %s", session_id) 

260 return True 

261 

262 async def revoke_all_sessions(self, user_id: str) -> bool: 

263 """Revoke all active sessions for a user.""" 

264 await self._store.delete_all_for_user(user_id) 

265 logger.info("Revoked all sessions for user: %s", user_id) 

266 return True 

267 

268 async def get_active_sessions(self, user_id: str) -> list[UserSession]: 

269 """List all active sessions for a user.""" 

270 return await self._store.list_for_user(user_id) 

271 

272 def _generate_fallback_id(self) -> str: 

273 """Generate session ID when IdGenerator is not injected.""" 

274 import uuid as uuid_module 

275 

276 return str(uuid_module.uuid4()) 

277 

278 

279__all__ = [ 

280 "SessionManagerImpl", 

281 "logger", 

282]