feat(sessions): identify online sessions (connected users)

identify online session by writing the `last_seen_at`
information is stored in a map and flush each 30s to prevent performance impact on pgsql
This commit is contained in:
Edouard Vanbelle
2026-08-19 10:10:39 +02:00
parent 9d69d9c7e7
commit 20e6e05bb4
16 changed files with 1317 additions and 37 deletions
+179 -1
View File
@@ -14,6 +14,8 @@
//! separate batch fetch (extra round-trip). Frontend cross-references
//! `user_id` against its cached user list.
use std::time::Duration;
use chrono::{DateTime, Utc};
use serde::Serialize;
use utoipa::ToSchema;
@@ -21,6 +23,20 @@ use uuid::Uuid;
use crate::domain::entities::session::{Session, SessionOrigin};
/// The "recently seen" threshold that turns a session's
/// `last_seen_at` into a green-dot "Online" badge on the admin
/// sessions panel — AND the same window that drives the
/// `oxicloud_sessions_online[_users]` Prometheus gauges (see
/// `src/infrastructure/services/session_liveness_gauges.rs`).
/// The two MUST agree so the dashboard's per-row badge count
/// matches the gauge's aggregate — one source of truth here.
///
/// 5 min feels responsive without over-fluctuating with
/// tab-open-then-close blips. Deliberately hardcoded, not an
/// env var — see `docs/plan/sessions.md` §"Config surface" for
/// the reasoning.
pub const ONLINE_WINDOW: Duration = Duration::from_secs(5 * 60);
/// Authenticated-caller context — the caller's identity + session-
/// bound signals a service method might key off. Constructed at the
/// handler boundary from `AuthUser` and passed through unchanged;
@@ -53,6 +69,17 @@ pub struct SessionSummaryDto {
pub user_id: Uuid,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
/// Wall-clock time this session was last observed serving an
/// authenticated request. Moved forward per request by the
/// in-process [`LastSeenTracker`](crate::infrastructure::services::last_seen_tracker)
/// via a batched UPDATE every 30 s — so this value trails the
/// true "last seen" by at most one flush interval on a running
/// server. On DB read it always converges after a graceful
/// shutdown flush. Distinct from `created_at`: that only moves
/// on session rotation (silent refresh), so its resolution is
/// capped at the access-token TTL. The admin table renders a
/// "last seen X ago" column off this field.
pub last_seen_at: DateTime<Utc>,
pub ip_address: Option<String>,
pub user_agent: Option<String>,
/// `true` iff the session is DPoP-bound. Rendered as a lock icon
@@ -68,7 +95,24 @@ pub struct SessionSummaryDto {
pub is_revoked: bool,
/// Whether this row is currently usable — `!revoked && expires_at > now()`.
/// Kept server-side so the SPA doesn't drift if the browser clock is off.
/// **Distinct from [`is_online`](Self::is_online)** — this is a
/// *lifecycle* signal (row still has authority), that one is a
/// *presence* signal (a request landed on it lately).
pub is_active: bool,
/// Whether the session was actually observed serving a request in the
/// last [`ONLINE_WINDOW`] (5 min). Presence signal, orthogonal to
/// [`is_active`](Self::is_active): a session may be active-and-online
/// (green dot in the admin table), active-and-idle (no dot, "last
/// seen 12 min ago"), or non-active-and-offline (expired / revoked
/// rows are never online). Derived server-side against
/// [`ONLINE_WINDOW`] so the row-level badge stays consistent with
/// the `oxicloud_sessions_online[_users]` Prometheus aggregates.
///
/// Guaranteed `false` for revoked / expired rows — those short-
/// circuit before the recency check so a revoked row that happened
/// to receive a request in its final second before revocation
/// doesn't confusingly render "Online" post-revocation.
pub is_online: bool,
// NOTE: no `oidc_sid` / `oidc_sid_prefix` field. The IdP-emitted
// sid identifies the row's upstream session and stays server-side
// (used by Back-Channel Logout matching). Exposing even a prefix
@@ -104,23 +148,40 @@ impl SessionSummaryDto {
pub fn from_session(s: Session, caller_jkt: Option<&str>) -> Self {
let is_revoked = s.is_revoked();
let is_expired = s.is_expired();
let is_active = !is_revoked && !is_expired;
let jkt = s.dpop_jkt().map(|s| s.to_owned());
let dpop_jkt_prefix = jkt.as_ref().map(|t| t.chars().take(8).collect::<String>());
let is_current = match (jkt.as_deref(), caller_jkt) {
(Some(row), Some(caller)) => row == caller,
_ => false,
};
// Presence check gated on lifecycle — a revoked or expired
// row's `last_seen_at` may still be fresh (the last request
// that arrived just before revocation), but calling it
// "Online" post-revocation would confuse an admin reading
// the panel. Short-circuit on !is_active.
let online_cutoff = match chrono::Duration::from_std(ONLINE_WINDOW) {
Ok(d) => Utc::now() - d,
// Cast can only fail on a Duration too large for i64
// milliseconds; not reachable with our 5 min constant.
// Fall back to "never online" rather than panic — a
// wrong badge is fixable, a request-path panic is not.
Err(_) => DateTime::<Utc>::MAX_UTC,
};
let is_online = is_active && s.last_seen_at() > online_cutoff;
Self {
id: s.id(),
user_id: s.user_id(),
created_at: s.created_at(),
expires_at: s.expires_at(),
last_seen_at: s.last_seen_at(),
ip_address: s.ip_address().map(str::to_owned),
user_agent: s.user_agent().map(str::to_owned),
is_bound: jkt.is_some(),
dpop_jkt_prefix,
is_revoked,
is_active: !is_revoked && !is_expired,
is_active,
is_online,
origin: s.origin(),
is_current,
}
@@ -166,6 +227,7 @@ mod tests {
Some(sid.to_string()),
None,
crate::domain::entities::session::SessionOrigin::Oidc,
Utc::now(),
)
}
@@ -241,6 +303,121 @@ mod tests {
assert!(dto.is_active);
}
#[test]
fn dto_exposes_last_seen_at() {
// Regression: the admin table renders "last seen X ago"
// straight off this field, and clients that build
// dashboards off the session API rely on it too. Guards
// against a struct field being removed / renamed silently.
let s = base(false, None);
let expected = s.last_seen_at();
let dto = SessionSummaryDto::from(s);
assert_eq!(dto.last_seen_at, expected);
let json = serde_json::to_string(&dto).unwrap();
assert!(
json.contains("\"last_seen_at\""),
"wire shape must include `last_seen_at`: {json}"
);
}
/// A freshly-minted, unbound, unrevoked session ships with
/// `last_seen_at = Utc::now()` from `Session::new`, so it
/// MUST render as online. This is the green-dot happy path
/// the admin panel keys off — regression here means the
/// dashboard misses every currently-active session.
#[test]
fn dto_is_online_when_last_seen_is_fresh() {
let dto = SessionSummaryDto::from(base(false, None));
assert!(dto.is_online, "fresh session must be online: {dto:?}");
assert!(dto.is_active);
}
/// A session whose `last_seen_at` is older than the
/// [`ONLINE_WINDOW`] MUST render as offline even when the
/// row is otherwise Active — that's the whole point of the
/// presence vs lifecycle split. Constructed via `from_raw`
/// so we can stamp a stale timestamp deterministically.
#[test]
fn dto_is_not_online_when_last_seen_is_stale() {
let stale = Utc::now() - chrono::Duration::hours(1);
let s = Session::from_raw(
Uuid::new_v4(),
Uuid::new_v4(),
"rt".to_string(),
Utc::now() + Duration::days(30),
None,
None,
stale,
false,
Uuid::new_v4(),
None,
None,
None,
SessionOrigin::Password,
stale,
);
let dto = SessionSummaryDto::from(s);
assert!(!dto.is_online, "1h-idle session must not be online");
assert!(dto.is_active, "stale-but-alive session stays active");
}
/// Anti-confusion guard: a revoked row whose `last_seen_at`
/// happens to be fresh (the last request that landed just
/// before revocation) must NOT surface as "Online" — an admin
/// reading the panel post-revocation expects the green dot
/// gone. `is_online` short-circuits on `!is_active`.
#[test]
fn dto_is_not_online_when_revoked_even_if_fresh() {
let dto = SessionSummaryDto::from(base(true, None));
assert!(dto.is_revoked);
assert!(!dto.is_active);
assert!(
!dto.is_online,
"revoked-but-fresh row must never render as online",
);
}
/// Same anti-confusion guard for expiry: a session that's
/// past `expires_at` but whose last request landed in the
/// last 5 min must not surface as online.
#[test]
fn dto_is_not_online_when_expired_even_if_fresh() {
let past = Utc::now() - Duration::days(1);
let s = Session::from_raw(
Uuid::new_v4(),
Uuid::new_v4(),
"rt".to_string(),
past, // expires_at in the past
None,
None,
past,
false,
Uuid::new_v4(),
None,
None,
None,
SessionOrigin::Password,
Utc::now(), // last_seen_at fresh
);
let dto = SessionSummaryDto::from(s);
assert!(!dto.is_active, "expired session is not active");
assert!(
!dto.is_online,
"expired-but-fresh row must never render as online",
);
}
#[test]
fn fresh_session_has_last_seen_equal_to_created_at() {
// The DB default is `NOW()` and `Session::new` mirrors
// that with `Utc::now()` for BOTH columns — so a
// freshly-minted session immediately counts as "recently
// active" for the liveness gauges rather than showing up
// as long-idle for the first flush interval.
let s = base(false, None);
assert_eq!(s.created_at(), s.last_seen_at());
}
#[test]
fn from_raw_expired_session_is_not_active() {
let past = Utc::now() - Duration::days(1);
@@ -258,6 +435,7 @@ mod tests {
None,
None,
crate::domain::entities::session::SessionOrigin::Unknown,
past,
);
let dto = SessionSummaryDto::from(s);
assert!(!dto.is_active);
+9
View File
@@ -63,6 +63,14 @@ pub struct TokenClaims {
/// The DPoP middleware reads it from the already-validated token
/// (no DB round trip) to enforce "bound session → proof required".
pub dpop_jkt: Option<String>,
/// Session identifier — the `auth.sessions.id` this access token
/// was minted for. Read by the auth middleware to stamp
/// per-session liveness via [`LastSeenTracker`](crate::infrastructure::services::last_seen_tracker)
/// with no DB round trip. `None` for tokens minted by builds
/// that predate the `sid` claim (backward compat during rollout;
/// harmless — the missing sid just means no stamp fires, and
/// the token still authenticates normally).
pub sid: Option<Uuid>,
}
/// Port for JWT token operations.
@@ -81,6 +89,7 @@ pub trait TokenServicePort: Send + Sync + 'static {
fn generate_access_token(
&self,
user: &User,
session_id: Option<Uuid>,
dpop_jkt: Option<&str>,
) -> Result<String, DomainError>;
@@ -1269,21 +1269,17 @@ impl AuthApplicationService {
None => None,
};
// Generate tokens using the injected token service. The
// access token carries the `cnf.jkt` binding when present,
// so the DPoP middleware can enforce "bound → proof required"
// straight from the already-validated JWT — no session-row
// lookup on the hot path.
let access_token = self
.token_service
.generate_access_token(&user, validated_jkt.as_deref())?;
let refresh_token = self.token_service.generate_refresh_token();
// Construct the session FIRST so its id is available to the
// token mint below — the `sid` claim lets the auth middleware
// stamp per-session liveness with no DB round trip. Order
// was reversed as part of the `last_seen_at` wiring
// (`docs/plan/sessions.md`).
//
// Save session — new login starts a new token family. DPoP
// binding is set at INSERT time and immutable thereafter (see
// `docs/plan/dpop.md` — a mutable bind would let an attacker
// downgrade a bound session by re-binding to their own key).
let refresh_token = self.token_service.generate_refresh_token();
let mut session = Session::new(
user.id(),
refresh_token.clone(),
@@ -1293,6 +1289,18 @@ impl AuthApplicationService {
Uuid::new_v4(),
origin,
);
// Generate tokens using the injected token service. The
// access token carries the `cnf.jkt` binding when present,
// so the DPoP middleware can enforce "bound → proof required"
// straight from the already-validated JWT — no session-row
// lookup on the hot path. `sid` correlates the token to the
// session row constructed just above.
let access_token = self.token_service.generate_access_token(
&user,
Some(session.id()),
validated_jkt.as_deref(),
)?;
if let Some(jkt) = validated_jkt {
// Success-path audit — records the bind so operators can
// correlate a session_id in the panel with the exact moment
@@ -1562,8 +1570,9 @@ impl AuthApplicationService {
// thread `dpop_jkt` into a GET body. Session is minted
// unbound; the SPA calls `POST /api/auth/dpop/bind`
// post-redirect to bind it (see Gate 3). Token accordingly
// ships without `cnf.jkt`.
let access_token = self.token_service.generate_access_token(&user, None)?;
// ships without `cnf.jkt`. Session constructed first so
// its id can feed the token's `sid` claim — see the login
// path above for the rationale.
let refresh_token = self.token_service.generate_refresh_token();
let session = Session::new(
user.id(),
@@ -1574,6 +1583,9 @@ impl AuthApplicationService {
Uuid::new_v4(),
crate::domain::entities::session::SessionOrigin::MagicLink,
);
let access_token =
self.token_service
.generate_access_token(&user, Some(session.id()), None)?;
self.session_storage.create_session(session).await?;
tracing::info!(
@@ -1715,16 +1727,12 @@ impl AuthApplicationService {
));
}
// Generate new tokens. Inherit the DPoP binding from the
// parent session so the refreshed access token carries the
// same `cnf.jkt` — otherwise every refresh would silently
// downgrade to unbound and the next request would 401 under
// Gate 9 enforcement (see Gate 7).
let access_token = self
.token_service
.generate_access_token(&user, session.dpop_jkt())?;
let new_refresh_token = self.token_service.generate_refresh_token();
// Rotate the session first so the new row's id is available
// to the token mint below — the `sid` claim tracks the
// freshly-inserted row, not the revoked parent. Order
// reversed as part of the `last_seen_at` wiring
// (`docs/plan/sessions.md`).
//
// New session inherits the family_id so reuse of any ancestor triggers
// full-family revocation. Revoking the old session and inserting the
// new one happen in ONE transaction (`rotate_session`) — this path
@@ -1738,6 +1746,7 @@ impl AuthApplicationService {
// refresh silently downgrade the session to unbound, and every
// subsequent request would fail DPoP verification once required
// mode enforces per-session binding.
let new_refresh_token = self.token_service.generate_refresh_token();
let mut new_session = Session::new(
user.id(),
new_refresh_token.clone(),
@@ -1770,6 +1779,16 @@ impl AuthApplicationService {
new_session = new_session.with_oidc_sid(sid.to_string());
}
// Mint the access token AFTER the new session is fully
// configured — the `sid` claim points at the new row's id,
// and `cnf.jkt` inherits from the parent so DPoP proof
// enforcement (Gate 9) still holds across the rotation.
let access_token = self.token_service.generate_access_token(
&user,
Some(new_session.id()),
session.dpop_jkt(),
)?;
self.session_storage
.rotate_session(session.id(), new_session)
.await?;
@@ -4608,8 +4627,8 @@ impl AuthApplicationService {
// through the browser's redirect chain. Session is minted
// unbound; the SPA calls `POST /api/auth/dpop/bind` post-
// redirect to bind it (see Gate 3). Token accordingly ships
// without `cnf.jkt`.
let access_token = self.token_service.generate_access_token(&user, None)?;
// without `cnf.jkt`. Session constructed first so its id
// feeds the token's `sid` claim.
let refresh_token = self.token_service.generate_refresh_token();
let mut session = Session::new(
@@ -4630,6 +4649,11 @@ impl AuthApplicationService {
if let Some(sid) = claims.sid.as_ref() {
session = session.with_oidc_sid(sid.clone());
}
// Mint AFTER session is fully configured so `sid` claim
// aligns with the row about to be inserted.
let access_token =
self.token_service
.generate_access_token(&user, Some(session.id()), None)?;
self.session_storage.create_session(session).await?;
let force_password_change = self.read_force_password_change(user.id()).await;
@@ -180,10 +180,12 @@ impl DeviceAuthService {
// clients that don't run WebCrypto — always unbound (`None`
// for the `dpop_jkt` param), which the DPoP middleware exempts
// from proof requirements. See `docs/plan/dpop.md` Gate 9.
let access_token = self.token_service.generate_access_token(&user, None)?;
//
// Session constructed first so its id feeds the token's
// `sid` claim — the auth middleware uses this to stamp
// per-session liveness without a DB round trip
// (`docs/plan/sessions.md`).
let refresh_token = self.token_service.generate_refresh_token();
// Persist refresh token as a session
let session = Session::new(
user_id,
refresh_token.clone(),
@@ -193,6 +195,9 @@ impl DeviceAuthService {
Uuid::new_v4(),
crate::domain::entities::session::SessionOrigin::Device,
);
let access_token =
self.token_service
.generate_access_token(&user, Some(session.id()), None)?;
self.session_storage.create_session(session).await?;
// Store tokens on the device code entity