Skip to main content

soma_auth/
session.rs

1use axum::http::header::{COOKIE, HeaderMap, SET_COOKIE};
2use axum::response::Response;
3
4use crate::error::AuthError;
5use crate::state::AuthState;
6use crate::types::BrowserSessionRow;
7use crate::util::{expires_at, now_unix, random_token};
8
9/// Default browser session cookie name used by the lab consumer. Other
10/// consumers should not read this constant directly — instead, prefer
11/// `AuthState.config.session_cookie_name` and the helpers in this module
12/// that look up the configured name from state.
13pub const BROWSER_SESSION_COOKIE_NAME: &str = "lab_session";
14pub const BROWSER_CSRF_HEADER_NAME: &str = "x-csrf-token";
15
16pub fn read_cookie(headers: &HeaderMap, name: &str) -> Option<String> {
17    headers
18        .get(COOKIE)
19        .and_then(|value| value.to_str().ok())
20        .and_then(|raw| {
21            raw.split(';').map(str::trim).find_map(|cookie| {
22                let (key, value) = cookie.split_once('=')?;
23                (key.trim() == name).then(|| value.trim().to_string())
24            })
25        })
26}
27
28pub fn append_set_cookie(response: &mut Response, cookie: &str) {
29    if let Ok(value) = cookie.parse() {
30        response.headers_mut().append(SET_COOKIE, value);
31    }
32}
33
34pub async fn create_browser_session(
35    state: &AuthState,
36    subject: String,
37    email: Option<String>,
38) -> Result<BrowserSessionRow, AuthError> {
39    let created_at = now_unix();
40    let session = BrowserSessionRow {
41        session_id: random_token(24)?,
42        subject,
43        email,
44        csrf_token: random_token(18)?,
45        created_at,
46        expires_at: expires_at(
47            created_at,
48            state.config.refresh_token_ttl,
49            &format!("{}_AUTH_REFRESH_TOKEN_TTL_SECS", state.config.env_prefix),
50        )?,
51    };
52    state.store.upsert_browser_session(session.clone()).await?;
53    Ok(session)
54}
55
56fn secure_cookie_attr(state: &AuthState) -> &'static str {
57    if state
58        .config
59        .public_url
60        .as_ref()
61        .is_none_or(|url| url.scheme() == "https")
62    {
63        "; Secure"
64    } else {
65        ""
66    }
67}
68
69pub fn build_browser_session_cookie(state: &AuthState, session_id: &str) -> String {
70    format!(
71        "{name}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}{secure}",
72        name = state.config.session_cookie_name,
73        value = session_id,
74        max_age = state.config.refresh_token_ttl.as_secs(),
75        secure = secure_cookie_attr(state),
76    )
77}
78
79pub fn clear_browser_session_cookie(state: &AuthState) -> String {
80    format!(
81        "{name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT{secure}",
82        name = state.config.session_cookie_name,
83        secure = secure_cookie_attr(state),
84    )
85}
86
87#[cfg(test)]
88mod tests {
89    use axum::http::HeaderMap;
90
91    use super::read_cookie;
92
93    #[test]
94    fn read_cookie_finds_named_cookie_even_when_it_is_not_first() {
95        let mut headers = HeaderMap::new();
96        headers.insert(
97            axum::http::header::COOKIE,
98            "theme=dark; lab_session=session-123; other=value"
99                .parse()
100                .unwrap(),
101        );
102
103        assert_eq!(
104            read_cookie(&headers, super::BROWSER_SESSION_COOKIE_NAME),
105            Some("session-123".to_string())
106        );
107    }
108
109    #[test]
110    fn read_cookie_ignores_unrelated_cookies() {
111        let mut headers = HeaderMap::new();
112        headers.insert(
113            axum::http::header::COOKIE,
114            "theme=dark; other=value".parse().unwrap(),
115        );
116
117        assert_eq!(
118            read_cookie(&headers, super::BROWSER_SESSION_COOKIE_NAME),
119            None
120        );
121    }
122
123    #[tokio::test]
124    async fn build_browser_session_cookie_uses_configured_name() {
125        use crate::authorize::tests::test_auth_state_with_config;
126        use crate::config::AuthConfig;
127
128        let mut config = AuthConfig::default();
129        // Need OAuth + bare minimums for AuthState::new.
130        config.mode = crate::config::AuthMode::OAuth;
131        config.public_url =
132            Some(url::Url::parse("https://syslog.example.com").expect("public url"));
133        config.google.client_id = "client-id".into();
134        config.google.client_secret = "client-secret".into();
135        // AuthConfig::default() -> GoogleConfig::default() leaves
136        // callback_path as "" (Default::default(), not the
137        // #[serde(default = ...)] value, which only applies during
138        // deserialization) — validate() now runs inside AuthState::new and
139        // rejects that, so this fixture must set it explicitly.
140        config.google.callback_path = "/auth/google/callback".to_string();
141        config.admin_email = "admin@example.com".into();
142        config.default_provider = "google".to_string();
143        let dir = tempfile::tempdir().expect("tempdir");
144        config.sqlite_path = dir.path().join("auth.db");
145        config.key_path = dir.path().join("auth.pem");
146        config.session_cookie_name = "syslog_session".to_string();
147
148        let state = test_auth_state_with_config(config).await;
149        let cookie = super::build_browser_session_cookie(&state, "abc");
150        assert!(cookie.starts_with("syslog_session=abc;"), "got: {cookie}");
151        assert!(!cookie.contains("lab_session"));
152
153        let cleared = super::clear_browser_session_cookie(&state);
154        assert!(cleared.starts_with("syslog_session=;"), "got: {cleared}");
155    }
156}