Skip to main content

systemprompt_api/services/middleware/session/
mod.rs

1//! Session-establishment middleware.
2//!
3//! [`SessionMiddleware`] resolves or mints the per-request session: it skips
4//! untracked paths, short-circuits detected bots into anonymous contexts,
5//! validates an existing JWT session, and refreshes or recreates the session
6//! when the token is stale, issuing a `Set-Cookie` for newly minted tokens.
7//!
8//! Validation runs through [`attest_session`], the same predicate the JWT and
9//! gateway credential paths use: a cookie must name a session the server issued
10//! *to that user*. An existence-only check would let a signed token borrow
11//! another user's live session for analytics attribution.
12//!
13//! Establishing a session reads and writes the database, so it is bounded and
14//! degrades rather than fails — see the `context` submodule. A request that
15//! cannot be given a session is served with an untracked, actor-less context
16//! instead of a 500: the alternative is that a database fault takes the public
17//! site down, and a page view is worth more than the analytics row describing
18//! it. Nothing is escalated by the degraded context — it carries no auth token
19//! and no user, so every gate above `public` still refuses it.
20//!
21//! Copyright (c) systemprompt.io — Business Source License 1.1.
22//! See <https://systemprompt.io> for licensing details.
23
24mod attestation;
25mod context;
26mod lifecycle;
27mod skip;
28
29pub use attestation::{SessionAttestationError, attest_session};
30pub use skip::should_skip_session_tracking;
31
32use axum::extract::{ConnectInfo, Request};
33use axum::http::header;
34use axum::middleware::Next;
35use axum::response::Response;
36use ipnet::IpNet;
37use std::net::SocketAddr;
38use std::sync::Arc;
39use systemprompt_analytics::{AnalyticsService, SessionAnalytics};
40use systemprompt_models::api::ApiError;
41use systemprompt_oauth::services::SessionCreationService;
42use systemprompt_runtime::AppContext;
43use systemprompt_security::{CookieExtractor, HeaderExtractor};
44use systemprompt_traits::{AnalyticsProvider, ExtractSignals};
45use systemprompt_users::UserService;
46
47struct RequestMeta<'a> {
48    headers: &'a http::HeaderMap,
49    uri: &'a http::Uri,
50    analytics: &'a SessionAnalytics,
51}
52
53#[derive(Clone, Debug)]
54pub struct SessionMiddleware {
55    analytics_service: Arc<AnalyticsService>,
56    session_creation_service: Arc<SessionCreationService>,
57    trusted_proxies: Arc<Vec<IpNet>>,
58    ignored_forwarded_warn: Arc<systemprompt_logging::LogThrottle>,
59    degraded_warn: Arc<systemprompt_logging::LogThrottle>,
60}
61
62const IGNORED_FORWARDED_WARN_INTERVAL_SECS: u64 = 3600;
63const DEGRADED_WARN_INTERVAL_SECS: u64 = 60;
64
65impl SessionMiddleware {
66    pub fn new(ctx: &AppContext) -> Self {
67        let user_service = UserService::new(Arc::clone(ctx.user_repository()));
68        let concrete = Arc::clone(ctx.analytics_service());
69        let analytics: Arc<dyn AnalyticsProvider> = concrete;
70        let session_creation_service = Arc::new(SessionCreationService::new(
71            analytics,
72            Arc::new(user_service),
73        ));
74
75        Self {
76            analytics_service: Arc::clone(ctx.analytics_service()),
77            session_creation_service,
78            trusted_proxies: Arc::new(ctx.config().trusted_proxies.clone()),
79            ignored_forwarded_warn: Arc::new(systemprompt_logging::LogThrottle::new(
80                IGNORED_FORWARDED_WARN_INTERVAL_SECS,
81            )),
82            degraded_warn: Arc::new(systemprompt_logging::LogThrottle::new(
83                DEGRADED_WARN_INTERVAL_SECS,
84            )),
85        }
86    }
87
88    pub async fn handle(&self, mut request: Request, next: Next) -> Result<Response, ApiError> {
89        let caller_ip = super::client_addr::resolve_client_ip(
90            request.headers(),
91            request.extensions().get::<ConnectInfo<SocketAddr>>(),
92            &self.trusted_proxies,
93        );
94        if let Some(peer) = request.extensions().get::<ConnectInfo<SocketAddr>>()
95            && super::client_addr::forwarded_headers_ignored(
96                request.headers(),
97                peer.0.ip(),
98                &self.trusted_proxies,
99            )
100            && self.ignored_forwarded_warn.allow()
101        {
102            tracing::warn!(
103                peer_ip = %peer.0.ip(),
104                "ignoring forwarded client-IP headers from untrusted private peer; if this \
105                 server runs behind a proxy, add the peer's range to server.trusted_proxies"
106            );
107        }
108        let uri = request.uri().clone();
109        let headers = request.headers();
110        let analytics = self.analytics_service.extract_analytics(
111            headers,
112            ExtractSignals {
113                uri: Some(&uri),
114                caller_ip,
115            },
116        );
117        let meta = RequestMeta {
118            headers,
119            uri: &uri,
120            analytics: &analytics,
121        };
122
123        let should_skip = should_skip_session_tracking(uri.path());
124
125        tracing::debug!(
126            path = %uri.path(),
127            should_skip = should_skip,
128            "Session middleware evaluating request"
129        );
130
131        let trace_id = HeaderExtractor::extract_trace_id(headers);
132
133        let (req_ctx, jwt_cookie) = self
134            .establish_or_degrade(should_skip, trace_id, &meta, uri.path())
135            .await;
136
137        tracing::debug!(
138            path = %uri.path(),
139            session_id = %req_ctx.session_id(),
140            "Session middleware setting context"
141        );
142
143        request.extensions_mut().insert(req_ctx);
144
145        let mut response = next.run(request).await;
146
147        if let Some(token) = jwt_cookie {
148            let cookie = format!(
149                "{}={token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=604800",
150                CookieExtractor::DEFAULT_COOKIE_NAME
151            );
152            if let Ok(cookie_value) = cookie.parse() {
153                response
154                    .headers_mut()
155                    .insert(header::SET_COOKIE, cookie_value);
156            }
157        }
158
159        Ok(response)
160    }
161}