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
9pub 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 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 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}