Skip to main content

sofie_http/
cookie.rs

1use std::sync::Arc;
2
3use http::header::COOKIE;
4use http::{HeaderMap, HeaderValue};
5use sofie_auth::{SessionClaims, SigningKeys};
6use sofie_core::{Error, Result, Timestamp};
7
8use crate::Serving;
9
10/// `__Host-` makes browsers refuse a cookie unless `Secure`, `Path=/` and without
11/// `Domain`, so no other host can set or shadow it.
12#[derive(Clone, Debug, PartialEq, Eq)]
13pub struct CookieNames {
14    session: Arc<str>,
15    #[cfg(all(feature = "toolbar", debug_assertions))]
16    toolbar: Arc<str>,
17    #[cfg(feature = "flows")]
18    flow: Arc<str>,
19}
20
21const SESSION_ATTRIBUTES: &str = "Path=/; Secure; HttpOnly; SameSite=Lax";
22#[cfg(feature = "flows")]
23const FLOW_SECONDS: i64 = 600;
24
25impl CookieNames {
26    /// The names `Serving::named` gave the app's cookies.
27    pub fn of_serving(serving: &Serving) -> Result<Self> {
28        Self::of(crate::AppName::of_serving(serving).as_str())
29    }
30
31    pub(crate) fn of(app: &str) -> Result<Self> {
32        if app.is_empty() || !app.bytes().all(is_token_byte) {
33            return Err(Error::internal_message(format!(
34                "the app name `{app}` cannot name a cookie: it must be RFC 9110 token characters"
35            )));
36        }
37        Ok(Self {
38            session: format!("__Host-{app}-session").into(),
39            #[cfg(all(feature = "toolbar", debug_assertions))]
40            toolbar: format!("__Host-{app}-toolbar").into(),
41            #[cfg(feature = "flows")]
42            flow: format!("__Host-{app}-flow").into(),
43        })
44    }
45
46    #[must_use]
47    pub fn session(&self) -> &str {
48        &self.session
49    }
50
51    #[cfg(all(feature = "toolbar", debug_assertions))]
52    pub(crate) fn toolbar(&self) -> &str {
53        &self.toolbar
54    }
55
56    pub(crate) fn session_value<'h>(&self, headers: &'h HeaderMap) -> Option<&'h str> {
57        named(headers, &self.session)
58    }
59
60    /// The idle timeout is enforced when the cookie is opened.
61    pub(crate) fn sealed_session(
62        &self,
63        keys: &SigningKeys,
64        claims: &SessionClaims,
65        now: Timestamp,
66    ) -> Result<HeaderValue> {
67        let sealed = claims.seal(keys)?;
68        set(&self.session, &sealed, claims.remaining_seconds(now))
69    }
70
71    pub(crate) fn session_removal(&self) -> Result<HeaderValue> {
72        set(&self.session, "", 0)
73    }
74
75    #[cfg(feature = "flows")]
76    pub(crate) fn flow_value<'h>(&self, headers: &'h HeaderMap) -> Option<&'h str> {
77        named(headers, &self.flow)
78    }
79
80    /// Lax, so the browser sends it back when a provider sends it to the app's callback.
81    #[cfg(feature = "flows")]
82    pub(crate) fn begun_flow(&self, value: &str) -> Result<HeaderValue> {
83        set(&self.flow, value, FLOW_SECONDS)
84    }
85}
86
87fn set(name: &str, value: &str, max_age: i64) -> Result<HeaderValue> {
88    HeaderValue::try_from(format!(
89        "{name}={value}; Max-Age={max_age}; {SESSION_ATTRIBUTES}"
90    ))
91    .map_err(|_| Error::internal_message(format!("the cookie `{name}` is not a header value")))
92}
93
94/// RFC 9110 `tchar`.
95fn is_token_byte(byte: u8) -> bool {
96    byte.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&byte)
97}
98
99/// Split as bytes: another cookie's non-ASCII value must not hide ours.
100pub(crate) fn named<'h>(headers: &'h HeaderMap, name: &str) -> Option<&'h str> {
101    headers
102        .get_all(COOKIE)
103        .iter()
104        .flat_map(|header| header.as_bytes().split(|byte| *byte == b';'))
105        .find_map(|pair| {
106            let value = pair
107                .trim_ascii()
108                .strip_prefix(name.as_bytes())?
109                .strip_prefix(b"=")?;
110            std::str::from_utf8(value).ok()
111        })
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn the_names_come_from_the_app() {
120        let names = CookieNames::of("islands-demo").unwrap();
121
122        assert_eq!(names.session(), "__Host-islands-demo-session");
123        #[cfg(all(feature = "toolbar", debug_assertions))]
124        assert_eq!(names.toolbar(), "__Host-islands-demo-toolbar");
125    }
126
127    #[test]
128    fn a_non_ascii_cookie_beside_ours_does_not_hide_it() {
129        let mut headers = HeaderMap::new();
130        headers.insert(
131            COOKIE,
132            HeaderValue::from_bytes("pref=Jürgen; __Host-shop-session=sealed".as_bytes()).unwrap(),
133        );
134
135        assert_eq!(named(&headers, "__Host-shop-session"), Some("sealed"));
136        assert_eq!(named(&headers, "pref"), Some("Jürgen"));
137    }
138
139    #[test]
140    fn a_name_that_cannot_name_a_cookie_is_refused() {
141        for app in ["", "my shop", "shop;x", "shop=1", "şop"] {
142            assert!(CookieNames::of(app).is_err(), "{app}");
143        }
144    }
145}