File size: 23,786 Bytes
517919c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
"""
Session security: concurrent-login detection, active-session management, and mid-session
IP/User-Agent anomaly detection.

Pen-test remediation. Everything here is inert unless SESSION_SECURITY_ENABLED=True
(tools/config.py). The registry is keyed on the app's already-resolved identity
(tools.gradio_platform.resolve_session_identity), so it works the same whether identity
comes from an ALB "authenticate-cognito" header, Gradio-native Cognito login, or (inertly,
since every "session" is then already unique) no auth at all.

What this can and cannot do:
  - Can: track sessions per username with IP/User-Agent/login/last-seen metadata; detect
    concurrent logins for the same user and notify and/or soft-terminate the older session
    at the app layer; keep an account activity logbook; let a user list/terminate their own
    other sessions; detect IP/User-Agent drift mid-session and react.
  - Cannot: directly delete another browser's load-balancer session cookie (e.g. ALB's
    AWSELBAuthSessionCookie) - that store is owned by the load balancer/IdP, not this app.
    SESSION_SECURITY_COGNITO_GLOBAL_SIGNOUT is a best-effort, delayed (next token refresh)
    mitigation at the Cognito layer, not an instant kill.
"""

from __future__ import annotations

import threading
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional

import boto3
from botocore.exceptions import (
    BotoCoreError,
    ClientError,
    NoCredentialsError,
    PartialCredentialsError,
)

from tools.config import (
    AWS_ACCESS_KEY,
    AWS_REGION,
    AWS_SECRET_KEY,
    AWS_USER_POOL_ID,
    COGNITO_AUTH,
    HOST_NAME,
    RUN_AWS_FUNCTIONS,
    SAVE_LOGS_TO_CSV,
    SAVE_LOGS_TO_DYNAMODB,
    SESSION_SECURITY_ANOMALY_ACTION,
    SESSION_SECURITY_BIND_IP,
    SESSION_SECURITY_BIND_USER_AGENT,
    SESSION_SECURITY_COGNITO_GLOBAL_SIGNOUT,
    SESSION_SECURITY_DYNAMODB_TABLE_NAME,
    SESSION_SECURITY_ENABLED,
    SESSION_SECURITY_IDLE_TIMEOUT_MINUTES,
    SESSION_SECURITY_MODE,
    SESSION_SECURITY_STORE_BACKEND,
)


def _now_iso() -> str:
    return datetime.now(timezone.utc).isoformat(timespec="seconds")


@dataclass
class SessionRecord:
    username: str
    session_hash: str
    ip_address: str = ""
    user_agent: str = ""
    login_time: str = field(default_factory=_now_iso)
    last_seen_time: str = field(default_factory=_now_iso)
    status: str = "active"  # active | terminated
    notice: Optional[str] = None

    def to_item(self) -> Dict[str, Any]:
        return {
            "session_hash": self.session_hash,
            "username": self.username,
            "ip_address": self.ip_address,
            "user_agent": self.user_agent,
            "login_time": self.login_time,
            "last_seen_time": self.last_seen_time,
            "status": self.status,
            "notice": self.notice,
        }

    @classmethod
    def from_item(cls, item: Dict[str, Any]) -> "SessionRecord":
        return cls(
            username=item.get("username", ""),
            session_hash=item.get("session_hash", ""),
            ip_address=item.get("ip_address", "") or "",
            user_agent=item.get("user_agent", "") or "",
            login_time=item.get("login_time") or _now_iso(),
            last_seen_time=item.get("last_seen_time") or _now_iso(),
            status=item.get("status") or "active",
            notice=item.get("notice") or None,
        )


@dataclass
class HeartbeatResult:
    status: str
    notice: Optional[str] = None


###
# Storage backends
###


class SessionStore:
    """Minimal storage interface for session records."""

    def get(self, session_hash: str) -> Optional[SessionRecord]:
        raise NotImplementedError

    def put(self, record: SessionRecord) -> None:
        raise NotImplementedError

    def list_for_user(self, username: str) -> List[SessionRecord]:
        raise NotImplementedError

    def delete(self, session_hash: str) -> None:
        raise NotImplementedError


class InMemorySessionStore(SessionStore):
    """Default store. Per-process only: fine for a single instance/task, but concurrent
    logins landing on different worker processes/replicas will not see each other. Use
    the DynamoDB backend for multi-replica deployments."""

    def __init__(self) -> None:
        self._lock = threading.Lock()
        self._records: Dict[str, SessionRecord] = {}

    def get(self, session_hash: str) -> Optional[SessionRecord]:
        with self._lock:
            return self._records.get(session_hash)

    def put(self, record: SessionRecord) -> None:
        with self._lock:
            self._records[record.session_hash] = record

    def list_for_user(self, username: str) -> List[SessionRecord]:
        with self._lock:
            return [r for r in self._records.values() if r.username == username]

    def delete(self, session_hash: str) -> None:
        with self._lock:
            self._records.pop(session_hash, None)


class DynamoDBSessionStore(SessionStore):
    """Shares session state across multiple app replicas/tasks via DynamoDB."""

    def __init__(self, table_name: str) -> None:
        self._table_name = table_name
        self._dynamodb = self._connect()
        self._table = self._ensure_table()

    @staticmethod
    def _connect():
        try:
            dynamodb = boto3.resource("dynamodb", region_name=AWS_REGION)
            dynamodb.meta.client.list_tables()
            return dynamodb
        except Exception as exc:
            if RUN_AWS_FUNCTIONS and AWS_ACCESS_KEY and AWS_SECRET_KEY:
                return boto3.resource(
                    "dynamodb",
                    aws_access_key_id=AWS_ACCESS_KEY,
                    aws_secret_access_key=AWS_SECRET_KEY,
                    region_name=AWS_REGION,
                )
            raise RuntimeError(
                f"AWS credentials for DynamoDB session store not found: {exc}"
            ) from exc

    def _ensure_table(self):
        try:
            table = self._dynamodb.Table(self._table_name)
            table.load()
            return table
        except ClientError as exc:
            if exc.response["Error"]["Code"] != "ResourceNotFoundException":
                raise
            table = self._dynamodb.create_table(
                TableName=self._table_name,
                KeySchema=[{"AttributeName": "session_hash", "KeyType": "HASH"}],
                AttributeDefinitions=[
                    {"AttributeName": "session_hash", "AttributeType": "S"}
                ],
                BillingMode="PAY_PER_REQUEST",
            )
            table.meta.client.get_waiter("table_exists").wait(
                TableName=self._table_name
            )
            return table

    def get(self, session_hash: str) -> Optional[SessionRecord]:
        response = self._table.get_item(Key={"session_hash": session_hash})
        item = response.get("Item")
        return SessionRecord.from_item(item) if item else None

    def put(self, record: SessionRecord) -> None:
        self._table.put_item(Item=record.to_item())

    def list_for_user(self, username: str) -> List[SessionRecord]:
        from boto3.dynamodb.conditions import Attr

        items: List[Dict[str, Any]] = []
        scan_kwargs: Dict[str, Any] = {
            "FilterExpression": Attr("username").eq(username)
        }
        while True:
            response = self._table.scan(**scan_kwargs)
            items.extend(response.get("Items", []))
            last_key = response.get("LastEvaluatedKey")
            if not last_key:
                break
            scan_kwargs["ExclusiveStartKey"] = last_key
        return [SessionRecord.from_item(item) for item in items]

    def delete(self, session_hash: str) -> None:
        self._table.delete_item(Key={"session_hash": session_hash})


_store: Optional[SessionStore] = None
_store_lock = threading.Lock()


def _get_store() -> SessionStore:
    global _store
    if _store is not None:
        return _store
    with _store_lock:
        if _store is None:
            if (SESSION_SECURITY_STORE_BACKEND or "memory").lower() == "dynamodb":
                try:
                    _store = DynamoDBSessionStore(SESSION_SECURITY_DYNAMODB_TABLE_NAME)
                except Exception as exc:
                    print(
                        "Session security: could not initialise DynamoDB session store, "
                        f"falling back to in-memory ({exc})"
                    )
                    _store = InMemorySessionStore()
            else:
                _store = InMemorySessionStore()
    return _store


def reset_store_for_tests() -> None:
    """Test helper: force a fresh in-memory store on next access."""
    global _store
    _store = None


###
# Account activity logbook (shared access log CSV / DynamoDB table)
###


def _log_event(
    *,
    session_hash: str,
    username: str,
    event: str,
    ip_address: str = "",
    user_agent: str = "",
    status: str = "",
    details: str = "",
) -> None:
    """Append a security event to the existing access log (same file / DynamoDB table)."""
    if not SAVE_LOGS_TO_CSV and not SAVE_LOGS_TO_DYNAMODB:
        return
    try:
        # Local import avoids a circular import at module load
        # (gradio_platform does not import session_security).
        from tools.gradio_platform import log_platform_access

        log_platform_access(
            session_hash,
            HOST_NAME,
            username=username,
            event=event,
            ip_address=ip_address,
            user_agent=user_agent,
            status=status,
            details=details,
        )
    except OSError as exc:
        print(f"Session security: activity log write failed ({exc})")
    except Exception as exc:  # pragma: no cover - defensive, must never break the app
        print(f"Session security: could not write activity log ({exc})")


###
# Cognito / Gradio best-effort session invalidation
###


def _cognito_global_sign_out(username: str) -> None:
    """
    Best-effort, delayed mitigation: invalidates the user's Cognito refresh tokens so that
    an ALB "authenticate-cognito" session (or any other Cognito-token consumer) is forced to
    re-authenticate the next time it tries to refresh. This does NOT immediately revoke an
    already-issued, unexpired access token, so an already-live session can remain usable for
    up to its access-token lifetime. It also cannot be scoped to a single sibling session -
    it affects every token issued for that username, including ones issued moments earlier
    for the very login that triggered this call. For that reason this is opt-in and off by
    default; do not treat it as an instant, targeted session kill.
    """
    if (
        not SESSION_SECURITY_COGNITO_GLOBAL_SIGNOUT
        or not AWS_USER_POOL_ID
        or not username
    ):
        return
    try:
        client = boto3.client("cognito-idp", region_name=AWS_REGION)
        client.admin_user_global_sign_out(
            UserPoolId=AWS_USER_POOL_ID, Username=username
        )
    except (
        ClientError,
        NoCredentialsError,
        PartialCredentialsError,
        BotoCoreError,
    ) as exc:
        print(
            "Session security: Cognito admin_user_global_sign_out failed "
            f"(check cognito-idp:AdminUserGlobalSignOut permission): {exc}"
        )
    except Exception as exc:  # pragma: no cover - defensive
        print(
            f"Session security: unexpected error during Cognito global sign-out: {exc}"
        )


_gradio_patch_lock = threading.Lock()
_gradio_patch_applied = False
_tracked_blocks: Any = None


def _ensure_gradio_app_capture_patch() -> None:
    """
    Best-effort monkeypatch of gradio.routes.App.configure_app so we can later reach the
    live App instance's ``.tokens`` dict (Gradio's own auth-cookie -> username map), in
    order to purge a user's Gradio-native login tokens immediately on invalidate. Only
    relevant when COGNITO_AUTH=True (Gradio's own login form, as opposed to ALB-header
    auth, where Gradio never owns the session cookie in the first place). Wrapped in
    try/except so that if Gradio's internals change shape in a future version, this
    quietly stops working instead of breaking the app.
    """
    global _gradio_patch_applied
    if _gradio_patch_applied:
        return
    with _gradio_patch_lock:
        if _gradio_patch_applied:
            return
        try:
            import gradio.routes as gr_routes

            original_configure_app = gr_routes.App.configure_app

            def _patched_configure_app(self, blocks):
                original_configure_app(self, blocks)
                try:
                    blocks._session_security_app_instance = self
                except Exception:  # pragma: no cover - defensive
                    pass

            gr_routes.App.configure_app = _patched_configure_app
            _gradio_patch_applied = True
        except Exception as exc:  # pragma: no cover - defensive
            print(
                f"Session security: could not attach Gradio App-capture patch ({exc})"
            )


def register_gradio_blocks(blocks: Any) -> None:
    """Call once with the app's gr.Blocks instance to enable Gradio-native token purge."""
    global _tracked_blocks
    if not SESSION_SECURITY_ENABLED or not COGNITO_AUTH:
        return
    _ensure_gradio_app_capture_patch()
    _tracked_blocks = blocks


def _purge_gradio_tokens(username: str) -> None:
    if not COGNITO_AUTH or _tracked_blocks is None or not username:
        return
    app_instance = getattr(_tracked_blocks, "_session_security_app_instance", None)
    if app_instance is None:
        return
    try:
        tokens = getattr(app_instance, "tokens", None)
        if not isinstance(tokens, dict):
            return
        for token in list(tokens.keys()):
            if tokens.get(token) == username:
                del tokens[token]
    except Exception as exc:  # pragma: no cover - defensive
        print(f"Session security: could not purge Gradio auth tokens ({exc})")


###
# Anomaly detection
###


def _detect_anomaly(
    record: SessionRecord, ip_address: str, user_agent: str
) -> Optional[str]:
    changes = []
    if (
        SESSION_SECURITY_BIND_IP
        and record.ip_address
        and ip_address
        and record.ip_address != ip_address
    ):
        changes.append(f"IP address changed from {record.ip_address} to {ip_address}")
    if (
        SESSION_SECURITY_BIND_USER_AGENT
        and record.user_agent
        and user_agent
        and record.user_agent != user_agent
    ):
        changes.append("User-Agent changed")
    return "; ".join(changes) if changes else None


def _pop_notice(store: SessionStore, record: SessionRecord) -> Optional[str]:
    notice = record.notice
    record.notice = None
    store.put(record)
    return notice


###
# Public API
###


def register_session(
    username: str, session_hash: str, ip_address: str = "", user_agent: str = ""
) -> Optional[str]:
    """
    Register (or refresh) an active session for username/session_hash. Detects other
    concurrent active sessions for the same username and applies SESSION_SECURITY_MODE
    to them (notify and/or invalidate). Returns a pending notice for *this* session_hash,
    if one was left by an earlier call (e.g. this tab itself was flagged before reloading).
    """
    if not SESSION_SECURITY_ENABLED or not username or not session_hash:
        return None

    store = _get_store()
    now = _now_iso()

    existing = store.get(session_hash)
    if existing is not None and existing.username == username:
        existing.ip_address = existing.ip_address or ip_address
        existing.user_agent = existing.user_agent or user_agent
        existing.last_seen_time = now
        return _pop_notice(store, existing)

    siblings = [
        r
        for r in store.list_for_user(username)
        if r.session_hash != session_hash and r.status == "active"
    ]

    record = SessionRecord(
        username=username,
        session_hash=session_hash,
        ip_address=ip_address,
        user_agent=user_agent,
        login_time=now,
        last_seen_time=now,
        status="active",
        notice=None,
    )
    store.put(record)
    _log_event(
        session_hash=session_hash,
        username=username,
        event="login",
        ip_address=ip_address,
        user_agent=user_agent,
        status="active",
    )

    if siblings:
        mode = (SESSION_SECURITY_MODE or "notify").lower()
        do_notify = mode in ("notify", "both")
        do_invalidate = mode in ("invalidate", "both")
        details = (
            f"New sign-in detected from {ip_address or 'an unknown location'} at {now}."
        )

        for sibling in siblings:
            if do_invalidate:
                sibling.status = "terminated"
                sibling.notice = (
                    "This session was ended because a new sign-in for your account was "
                    "detected. If this wasn't you, please review your account activity."
                )
                event = "invalidated_by_new_login"
            elif do_notify:
                sibling.notice = (
                    f"A new sign-in for your account was detected from "
                    f"{ip_address or 'a different location'}. If this wasn't you, please "
                    "review your account activity."
                )
                event = "notified_of_new_login"
            else:
                event = None

            if event:
                _log_event(
                    session_hash=sibling.session_hash,
                    username=username,
                    event=event,
                    ip_address=sibling.ip_address,
                    user_agent=sibling.user_agent,
                    status=sibling.status,
                    details=details,
                )
            store.put(sibling)

        if do_invalidate:
            _cognito_global_sign_out(username)
            _purge_gradio_tokens(username)

    return None


def heartbeat(
    session_hash: str, ip_address: str = "", user_agent: str = ""
) -> HeartbeatResult:
    """
    Periodic client poll: updates idle/last-seen tracking, checks for IP/User-Agent drift,
    enforces an optional idle timeout, and surfaces any pending notice (new login elsewhere,
    anomaly, manual termination) to the calling tab.
    """
    if not SESSION_SECURITY_ENABLED or not session_hash:
        return HeartbeatResult(status="active", notice=None)

    store = _get_store()
    record = store.get(session_hash)
    if record is None:
        return HeartbeatResult(status="active", notice=None)

    if record.status == "terminated":
        return HeartbeatResult(status="terminated", notice=_pop_notice(store, record))

    anomaly = _detect_anomaly(record, ip_address, user_agent)
    if anomaly:
        action = (SESSION_SECURITY_ANOMALY_ACTION or "notify").lower()
        details = f"Property change detected: {anomaly}"
        if action == "terminate":
            record.status = "terminated"
            record.notice = (
                "This session was ended because we detected a change in your connection "
                "details (possible session hijacking). Please reload and sign in again."
            )
            _log_event(
                session_hash=session_hash,
                username=record.username,
                event="anomaly_terminated",
                ip_address=ip_address,
                user_agent=user_agent,
                status="terminated",
                details=details,
            )
            return HeartbeatResult(
                status="terminated", notice=_pop_notice(store, record)
            )
        if action == "notify":
            record.notice = (
                "We noticed a change in your connection details during this session. If "
                "this wasn't expected, please check your account activity."
            )
            _log_event(
                session_hash=session_hash,
                username=record.username,
                event="anomaly_notified",
                ip_address=ip_address,
                user_agent=user_agent,
                status=record.status,
                details=details,
            )
        else:
            _log_event(
                session_hash=session_hash,
                username=record.username,
                event="anomaly_logged",
                ip_address=ip_address,
                user_agent=user_agent,
                status=record.status,
                details=details,
            )

    if (
        SESSION_SECURITY_IDLE_TIMEOUT_MINUTES
        and SESSION_SECURITY_IDLE_TIMEOUT_MINUTES > 0
    ):
        try:
            last_seen_dt = datetime.fromisoformat(record.last_seen_time)
        except ValueError:
            last_seen_dt = datetime.now(timezone.utc)
        idle_minutes = (
            datetime.now(timezone.utc) - last_seen_dt
        ).total_seconds() / 60.0
        if idle_minutes >= SESSION_SECURITY_IDLE_TIMEOUT_MINUTES:
            record.status = "terminated"
            record.notice = (
                "This session ended due to inactivity. Please reload and sign in again."
            )
            _log_event(
                session_hash=session_hash,
                username=record.username,
                event="idle_timeout",
                ip_address=ip_address,
                user_agent=user_agent,
                status="terminated",
            )
            return HeartbeatResult(
                status="terminated", notice=_pop_notice(store, record)
            )

    record.last_seen_time = _now_iso()
    record.ip_address = record.ip_address or ip_address
    record.user_agent = record.user_agent or user_agent
    return HeartbeatResult(status="active", notice=_pop_notice(store, record))


def list_sessions(username: str) -> List[SessionRecord]:
    """List all known sessions (active and terminated) for a username, most recent first."""
    if not SESSION_SECURITY_ENABLED or not username:
        return []
    records = _get_store().list_for_user(username)
    records.sort(key=lambda r: r.last_seen_time, reverse=True)
    return records


def terminate_sessions(
    username: str, session_hashes: List[str], actor_session_hash: str = ""
) -> int:
    """
    Manually terminate a user's own other sessions at the app layer. Ownership-checked:
    only sessions belonging to `username` are affected, and the caller's own current
    session_hash (`actor_session_hash`) is always skipped (use the normal logout control
    to end the session you are currently acting from).
    """
    if not SESSION_SECURITY_ENABLED or not username or not session_hashes:
        return 0

    store = _get_store()
    terminated = 0
    for session_hash in session_hashes:
        if not session_hash or session_hash == actor_session_hash:
            continue
        record = store.get(session_hash)
        if (
            record is None
            or record.username != username
            or record.status == "terminated"
        ):
            continue
        record.status = "terminated"
        record.notice = (
            "This session was remotely ended by you from another tab/device."
        )
        store.put(record)
        _log_event(
            session_hash=session_hash,
            username=username,
            event="manual_terminate",
            ip_address=record.ip_address,
            user_agent=record.user_agent,
            status="terminated",
        )
        terminated += 1
    return terminated