1use axum::http::{HeaderMap, HeaderValue, header};
8use base64::Engine as _;
9use base64::engine::general_purpose::URL_SAFE_NO_PAD;
10use hmac::{Hmac, Mac};
11use prospero_core::auth::{TokenEntry, TokenSet, constant_time_eq};
12use sha2::Sha256;
13
14use super::SessionKey;
15
16pub const COOKIE_NAME: &str = "prospero_session";
18pub const SESSION_TTL_SECS: i64 = 12 * 60 * 60;
20
21fn mac(key: &SessionKey, name: &str, expires_unix: i64, hash: &[u8; 32]) -> String {
22 let mut m =
23 Hmac::<Sha256>::new_from_slice(key.as_bytes()).expect("HMAC accepts any key length");
24 m.update(format!("v1\n{name}\n{expires_unix}\n").as_bytes());
25 m.update(&hash[..16]);
26 URL_SAFE_NO_PAD.encode(m.finalize().into_bytes())
27}
28
29pub fn sign(key: &SessionKey, entry: &TokenEntry, expires_unix: i64) -> String {
31 format!(
32 "v1.{}.{expires_unix}.{}",
33 entry.name,
34 mac(key, &entry.name, expires_unix, &entry.hash)
35 )
36}
37
38pub fn verify<'a>(
41 key: &SessionKey,
42 tokens: &'a TokenSet,
43 value: &str,
44 now_unix: i64,
45) -> Option<(&'a TokenEntry, i64)> {
46 let mut parts = value.splitn(4, '.');
47 let (version, name, expires, given) =
48 (parts.next()?, parts.next()?, parts.next()?, parts.next()?);
49 if version != "v1" {
50 return None;
51 }
52 let expires: i64 = expires.parse().ok()?;
53 if expires <= now_unix {
54 return None;
55 }
56 let entry = tokens.by_name(name)?;
57 let expected = mac(key, name, expires, &entry.hash);
58 constant_time_eq(expected.as_bytes(), given.as_bytes()).then_some((entry, expires))
59}
60
61pub fn cookie_value(headers: &HeaderMap) -> Option<&str> {
63 headers
64 .get_all(header::COOKIE)
65 .iter()
66 .filter_map(|v| v.to_str().ok())
67 .flat_map(|v| v.split(';'))
68 .find_map(|pair| pair.trim().strip_prefix("prospero_session="))
69}
70
71pub fn same_origin(headers: &HeaderMap) -> bool {
73 let get = |name: &str| headers.get(name).and_then(|v| v.to_str().ok());
74 if let Some(site) = get("sec-fetch-site") {
75 return site == "same-origin";
76 }
77 match (get("origin"), get("host")) {
78 (Some(origin), Some(host)) => origin.split_once("://").map(|(_, h)| h) == Some(host),
79 _ => false,
80 }
81}
82
83pub fn is_https(headers: &HeaderMap) -> bool {
85 headers
86 .get("x-forwarded-proto")
87 .and_then(|v| v.to_str().ok())
88 .is_some_and(|p| p.eq_ignore_ascii_case("https"))
89}
90
91pub fn set_cookie(value: &str, max_age_secs: i64, secure: bool) -> HeaderValue {
93 let mut s =
94 format!("{COOKIE_NAME}={value}; HttpOnly; SameSite=Strict; Path=/; Max-Age={max_age_secs}");
95 if secure {
96 s.push_str("; Secure");
97 }
98 HeaderValue::from_str(&s).expect("cookie attributes are ASCII")
99}
100
101pub fn clear_cookie(secure: bool) -> HeaderValue {
103 set_cookie("", 0, secure)
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109 use axum::http::HeaderValue;
110 use prospero_core::Scope;
111 use prospero_core::auth::{generate_token, tokens_file_line};
112
113 fn set_with(name: &str, token: &str) -> TokenSet {
114 TokenSet::parse(&tokens_file_line(name, Scope::Operate, token)).unwrap()
115 }
116
117 fn key() -> SessionKey {
118 SessionKey::from_bytes(vec![42; 32]).unwrap()
119 }
120
121 #[test]
122 fn signed_cookie_verifies_until_expiry() {
123 let tokens = set_with("ops", &generate_token());
124 let entry = tokens.by_name("ops").unwrap();
125 let value = sign(&key(), entry, 1_000);
126 let (e, exp) = verify(&key(), &tokens, &value, 999).unwrap();
127 assert_eq!((e.name.as_str(), exp), ("ops", 1_000));
128 assert!(
129 verify(&key(), &tokens, &value, 1_000).is_none(),
130 "expired at the boundary"
131 );
132 }
133
134 #[test]
135 fn tampering_wrong_key_and_bad_version_are_rejected() {
136 let tokens = set_with("ops", &generate_token());
137 let value = sign(&key(), tokens.by_name("ops").unwrap(), 1_000);
138 let tampered = value.replacen(".1000.", ".9999.", 1);
139 assert!(verify(&key(), &tokens, &tampered, 0).is_none());
140 let other = SessionKey::from_bytes(vec![1; 32]).unwrap();
141 assert!(verify(&other, &tokens, &value, 0).is_none());
142 assert!(verify(&key(), &tokens, &value.replacen("v1.", "v2.", 1), 0).is_none());
143 assert!(verify(&key(), &tokens, "garbage", 0).is_none());
144 }
145
146 #[test]
147 fn removing_or_rotating_the_token_invalidates_its_cookies() {
148 let tokens = set_with("ops", &generate_token());
149 let value = sign(&key(), tokens.by_name("ops").unwrap(), 1_000);
150 let removed = set_with("someone-else", &generate_token());
151 assert!(verify(&key(), &removed, &value, 0).is_none());
152 let rotated = set_with("ops", &generate_token());
153 assert!(verify(&key(), &rotated, &value, 0).is_none());
154 }
155
156 #[test]
157 fn cookie_value_is_found_among_other_cookies() {
158 let mut h = HeaderMap::new();
159 h.insert(
160 header::COOKIE,
161 HeaderValue::from_static("theme=dark; prospero_session=v1.ops.5.abc; x=y"),
162 );
163 assert_eq!(cookie_value(&h), Some("v1.ops.5.abc"));
164 assert_eq!(cookie_value(&HeaderMap::new()), None);
165 }
166
167 #[test]
168 fn same_origin_rules() {
169 let mk = |pairs: &[(&str, &str)]| {
170 let mut h = HeaderMap::new();
171 for (k, v) in pairs {
172 h.insert(
173 axum::http::HeaderName::from_bytes(k.as_bytes()).unwrap(),
174 HeaderValue::from_str(v).unwrap(),
175 );
176 }
177 h
178 };
179 assert!(same_origin(&mk(&[("sec-fetch-site", "same-origin")])));
180 assert!(!same_origin(&mk(&[("sec-fetch-site", "cross-site")])));
181 assert!(!same_origin(&mk(&[("sec-fetch-site", "same-site")])));
182 assert!(same_origin(&mk(&[
183 ("origin", "https://prospero.example"),
184 ("host", "prospero.example")
185 ])));
186 assert!(same_origin(&mk(&[
187 ("origin", "http://127.0.0.1:7878"),
188 ("host", "127.0.0.1:7878")
189 ])));
190 assert!(!same_origin(&mk(&[
191 ("origin", "https://evil.example"),
192 ("host", "prospero.example")
193 ])));
194 assert!(!same_origin(&HeaderMap::new()));
195 }
196
197 #[test]
198 fn set_cookie_attributes() {
199 let c = set_cookie("v", 60, false);
200 let s = c.to_str().unwrap();
201 assert!(s.starts_with("prospero_session=v;"));
202 for attr in ["HttpOnly", "SameSite=Strict", "Path=/", "Max-Age=60"] {
203 assert!(s.contains(attr), "{s}");
204 }
205 assert!(!s.contains("Secure"));
206 assert!(
207 set_cookie("v", 60, true)
208 .to_str()
209 .unwrap()
210 .contains("; Secure")
211 );
212 assert!(clear_cookie(false).to_str().unwrap().contains("Max-Age=0"));
213 }
214}