Skip to main content

prospero_api/auth/
session.rs

1//! Stateless dashboard session cookies (#2).
2//!
3//! `prospero_session=v1.<name>.<expires>.<mac>`; the MAC binds the token name,
4//! expiry and the first 16 bytes of the token's *current* hash, so removing or
5//! rotating a token invalidates its sessions on every replica with no store.
6
7use 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
16/// Cookie name.
17pub const COOKIE_NAME: &str = "prospero_session";
18/// Fixed session lifetime (12 h).
19pub 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
29/// The cookie value for `entry`, valid until `expires_unix`.
30pub 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
38/// Verify a cookie value against the current tokens. Token names cannot contain
39/// `.` and base64url has no `.`, so the value splits unambiguously.
40pub 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
61/// The `prospero_session` value from the request's `Cookie` headers.
62pub 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
71/// Same-origin test for cookie-authenticated mutations.
72pub 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
83/// Whether a TLS-terminating proxy reported HTTPS.
84pub 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
91/// A `Set-Cookie` value.
92pub 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
101/// A `Set-Cookie` value that deletes the session.
102pub 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}