Skip to main content

doido_auth/
session.rs

1//! Cookie/session strategy — integrates with `doido_controller::session`.
2
3use crate::config::AuthConfig;
4use crate::identity::AuthIdentity;
5use crate::strategy::AuthStrategy;
6use async_trait::async_trait;
7use doido_controller::session::{EncryptedCookieSessionStore, Session};
8use doido_core::Result;
9use doido_model::sea_orm::DatabaseConnection;
10use http::header;
11use http::request::Parts;
12
13/// Session data key for the authenticated user's id.
14pub const USER_ID_KEY: &str = "user_id";
15
16/// Session data key for the sign-in timestamp (used by the `timeoutable` module).
17pub const SIGNED_IN_AT_KEY: &str = "signed_in_at";
18
19/// Cookie name for the encrypted session (matches `doido_controller::context`).
20pub const SESSION_COOKIE: &str = "_doido_session";
21
22/// Cookie/session strategy using the encrypted cookie store.
23pub struct SessionStrategy {
24    store: EncryptedCookieSessionStore,
25}
26
27impl SessionStrategy {
28    pub fn new(secret: impl Into<Vec<u8>>) -> Self {
29        Self {
30            store: EncryptedCookieSessionStore::new(secret),
31        }
32    }
33
34    pub fn default_dev() -> Self {
35        Self {
36            store: EncryptedCookieSessionStore::default(),
37        }
38    }
39
40    fn session_from_parts(&self, parts: &Parts) -> Option<Session> {
41        let header = parts.headers.get(header::COOKIE)?.to_str().ok()?;
42        let raw = header
43            .split(';')
44            .filter_map(|pair| pair.trim().split_once('='))
45            .find(|(k, _)| *k == SESSION_COOKIE)
46            .map(|(_, v)| v.to_string())?;
47        self.store.decode(&raw)
48    }
49}
50
51#[async_trait]
52impl AuthStrategy for SessionStrategy {
53    fn name(&self) -> &str {
54        "cookie"
55    }
56
57    async fn authenticate(
58        &self,
59        parts: &Parts,
60        _db: &DatabaseConnection,
61    ) -> Result<Option<AuthIdentity>> {
62        let session = match self.session_from_parts(parts) {
63            Some(s) => s,
64            None => return Ok(None),
65        };
66        let user_id = match session.data.get(USER_ID_KEY) {
67            Some(value) => value.clone(),
68            None => return Ok(None),
69        };
70        if user_id.is_null() {
71            return Ok(None);
72        }
73        // `timeoutable` module: reject sessions older than `auth.timeout`.
74        if is_session_expired(&session) {
75            return Ok(None);
76        }
77        Ok(Some(AuthIdentity { user_id }))
78    }
79}
80
81/// Store `user_id` in the session bag (call after successful sign-in). Also
82/// stamps the sign-in time so the `timeoutable` module can expire stale sessions.
83pub fn sign_in_session(session: &mut Session, user_id: impl serde::Serialize) {
84    session.set(USER_ID_KEY, user_id);
85    session.set(SIGNED_IN_AT_KEY, chrono::Utc::now().timestamp());
86}
87
88/// Whether the session has exceeded `auth.timeout` since sign-in, when the
89/// `timeoutable` module is enabled. Absolute (session-age) timeout; idle-reset is
90/// a follow-up. Returns `false` when the module is disabled, auth state isn't
91/// initialised, or the session predates timestamp stamping.
92pub fn is_session_expired(session: &Session) -> bool {
93    let state = match crate::state::try_global() {
94        Some(state) => state,
95        None => return false,
96    };
97    if !state
98        .config
99        .has_module(crate::config::AuthModule::Timeoutable)
100    {
101        return false;
102    }
103    match session.data.get(SIGNED_IN_AT_KEY).and_then(|v| v.as_i64()) {
104        Some(signed_in_at) => {
105            chrono::Utc::now().timestamp() - signed_in_at > state.config.timeout as i64
106        }
107        None => false,
108    }
109}
110
111/// Clear the authenticated user from the session.
112pub fn sign_out_session(session: &mut Session) {
113    if session.data.is_object() {
114        if let serde_json::Value::Object(map) = &mut session.data {
115            map.remove(USER_ID_KEY);
116        }
117    }
118}
119
120/// Encode the session into a `Set-Cookie` value for `_doido_session`.
121pub fn encode_session_cookie(store: &EncryptedCookieSessionStore, session: &Session) -> String {
122    let value = store.encode(session);
123    format!("{SESSION_COOKIE}={value}; Path=/; HttpOnly; SameSite=Lax")
124}
125
126/// Clear the session cookie in a response.
127pub fn clear_session_cookie() -> String {
128    format!("{SESSION_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax")
129}
130
131/// Build a session strategy from config (uses the process-global secret key base).
132pub fn from_config(_config: &AuthConfig) -> SessionStrategy {
133    SessionStrategy::new(doido_controller::secret::key_base())
134}