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#[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 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 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 #[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
94fn is_token_byte(byte: u8) -> bool {
96 byte.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&byte)
97}
98
99pub(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}