Skip to main content

dioxus_cloudflare/
cookie.rs

1//! Cookie helpers for authentication flows.
2//!
3//! ## Outgoing cookies
4//!
5//! [`set_cookie`] and [`set_cookie_with`] queue `Set-Cookie` headers with secure
6//! defaults (`HttpOnly`, `Secure`, `SameSite=Strict`, `Path=/`).
7//! [`clear_cookie`] queues a `Max-Age=0` header to delete a cookie.
8//!
9//! Cookies are queued in thread-local storage and applied to the outgoing
10//! `worker::Response` by [`crate::handle`] — no `&mut Response` needed.
11//!
12//! ## Incoming cookies
13//!
14//! [`cookie`] reads a named cookie from the request `Cookie` header.
15//! [`cookies`] parses all cookies into `(name, value)` pairs.
16
17use std::fmt;
18
19use crate::context::push_cookie;
20use crate::error::CfError;
21
22// ---------------------------------------------------------------------------
23// Reading cookies from the incoming request
24// ---------------------------------------------------------------------------
25
26/// Read a named cookie from the incoming request's `Cookie` header.
27///
28/// Returns `Ok(None)` if the cookie is not present.
29///
30/// # Errors
31///
32/// Returns [`CfError`] if the `Cookie` header cannot be read.
33///
34/// # Example
35///
36/// ```rust,ignore
37/// use dioxus_cloudflare::prelude::*;
38///
39/// #[server]
40/// pub async fn who_am_i() -> Result<String, ServerFnError> {
41///     let session = cf::cookie("session")?.unwrap_or_default();
42///     Ok(session)
43/// }
44/// ```
45pub fn cookie(name: &str) -> Result<Option<String>, CfError> {
46    let header = crate::context::req()
47        .headers()
48        .get("Cookie")
49        .map_err(CfError)?
50        .unwrap_or_default();
51
52    Ok(parse_cookie_value(&header, name))
53}
54
55/// Read all cookies from the incoming request's `Cookie` header.
56///
57/// Returns an empty `Vec` if no `Cookie` header is present.
58///
59/// # Errors
60///
61/// Returns [`CfError`] if the `Cookie` header cannot be read.
62///
63/// # Example
64///
65/// ```rust,ignore
66/// use dioxus_cloudflare::prelude::*;
67///
68/// #[server]
69/// pub async fn list_cookies() -> Result<Vec<(String, String)>, ServerFnError> {
70///     let all = cf::cookies()?;
71///     Ok(all)
72/// }
73/// ```
74pub fn cookies() -> Result<Vec<(String, String)>, CfError> {
75    let header = crate::context::req()
76        .headers()
77        .get("Cookie")
78        .map_err(CfError)?
79        .unwrap_or_default();
80
81    Ok(parse_all_cookies(&header))
82}
83
84/// Parse a single cookie value by name from a `Cookie` header string.
85fn parse_cookie_value(header: &str, name: &str) -> Option<String> {
86    header.split(';').find_map(|pair| {
87        let mut parts = pair.splitn(2, '=');
88        let key = parts.next()?.trim();
89        let val = parts.next()?.trim();
90        if key == name { Some(val.to_owned()) } else { None }
91    })
92}
93
94/// Parse all `name=value` pairs from a `Cookie` header string.
95fn parse_all_cookies(header: &str) -> Vec<(String, String)> {
96    if header.is_empty() {
97        return Vec::new();
98    }
99    header
100        .split(';')
101        .filter_map(|pair| {
102            let mut parts = pair.splitn(2, '=');
103            let key = parts.next()?.trim();
104            let val = parts.next()?.trim();
105            if key.is_empty() {
106                None
107            } else {
108                Some((key.to_owned(), val.to_owned()))
109            }
110        })
111        .collect()
112}
113
114// ---------------------------------------------------------------------------
115// Writing cookies to the outgoing response
116// ---------------------------------------------------------------------------
117
118/// Queue an `HttpOnly` auth cookie on the outgoing response.
119///
120/// The cookie is configured with:
121/// - `HttpOnly` — not accessible via JavaScript (prevents XSS token theft)
122/// - `Secure` — only sent over HTTPS
123/// - `SameSite=Strict` — not sent on cross-origin requests (prevents CSRF)
124/// - `Path=/` — available on all routes
125///
126/// For custom cookie options, use [`set_cookie_with`] instead.
127///
128/// # Example
129///
130/// ```rust,ignore
131/// use dioxus_cloudflare::cf;
132///
133/// #[server]
134/// pub async fn login(token: String) -> Result<(), ServerFnError> {
135///     cf::set_cookie("session", &token, 60 * 60 * 24 * 7);
136///     Ok(())
137/// }
138/// ```
139pub fn set_cookie(name: &str, value: &str, max_age_secs: u64) {
140    push_cookie(format!(
141        "{name}={value}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age={max_age_secs}"
142    ));
143}
144
145/// Queue a cookie-clearing header on the outgoing response (`Max-Age=0`).
146///
147/// Used for logout flows. The browser will delete the cookie on the next
148/// response.
149///
150/// # Example
151///
152/// ```rust,ignore
153/// use dioxus_cloudflare::cf;
154///
155/// #[server]
156/// pub async fn logout() -> Result<(), ServerFnError> {
157///     cf::clear_cookie("session");
158///     Ok(())
159/// }
160/// ```
161pub fn clear_cookie(name: &str) {
162    push_cookie(format!(
163        "{name}=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0"
164    ));
165}
166
167/// Start building a cookie with custom options.
168///
169/// Returns a [`CookieBuilder`] with secure defaults (same as [`set_cookie`]).
170/// Override individual options with builder methods, then call [`.send()`](CookieBuilder::send)
171/// to queue the cookie.
172///
173/// # Example
174///
175/// ```rust,ignore
176/// use dioxus_cloudflare::prelude::*;
177///
178/// #[server]
179/// pub async fn set_theme(theme: String) -> Result<(), ServerFnError> {
180///     cf::set_cookie_with("theme", &theme)
181///         .http_only(false)
182///         .same_site(SameSite::Lax)
183///         .max_age(86400 * 30)
184///         .send();
185///     Ok(())
186/// }
187/// ```
188pub fn set_cookie_with(name: &str, value: &str) -> CookieBuilder {
189    CookieBuilder {
190        name: name.to_owned(),
191        value: value.to_owned(),
192        http_only: true,
193        secure: true,
194        same_site: SameSite::Strict,
195        path: "/".to_owned(),
196        max_age: None,
197        domain: None,
198    }
199}
200
201/// `SameSite` attribute for cookies.
202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
203pub enum SameSite {
204    /// Cookie is only sent in first-party context. Strongest CSRF protection.
205    Strict,
206    /// Cookie is sent on top-level navigations from external sites.
207    /// Good default for most use cases.
208    Lax,
209    /// Cookie is sent on all cross-site requests. Requires `Secure`.
210    None,
211}
212
213impl fmt::Display for SameSite {
214    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
215        match self {
216            Self::Strict => write!(f, "Strict"),
217            Self::Lax => write!(f, "Lax"),
218            Self::None => write!(f, "None"),
219        }
220    }
221}
222
223/// Builder for cookies with custom options.
224///
225/// Created by [`set_cookie_with`]. Defaults match [`set_cookie`]:
226/// `HttpOnly`, `Secure`, `SameSite=Strict`, `Path=/`.
227///
228/// Call [`.send()`](Self::send) to queue the cookie on the outgoing response.
229#[must_use = "cookie builder does nothing until .send() is called"]
230pub struct CookieBuilder {
231    name: String,
232    value: String,
233    http_only: bool,
234    secure: bool,
235    same_site: SameSite,
236    path: String,
237    max_age: Option<u64>,
238    domain: Option<String>,
239}
240
241impl CookieBuilder {
242    /// Set the `HttpOnly` flag. Default: `true`.
243    ///
244    /// Set to `false` for cookies that JavaScript needs to read (e.g., theme
245    /// preferences). Never disable for auth tokens.
246    pub fn http_only(mut self, yes: bool) -> Self {
247        self.http_only = yes;
248        self
249    }
250
251    /// Set the `Secure` flag. Default: `true`.
252    ///
253    /// Set to `false` only for local development over HTTP.
254    pub fn secure(mut self, yes: bool) -> Self {
255        self.secure = yes;
256        self
257    }
258
259    /// Set the `SameSite` attribute. Default: [`SameSite::Strict`].
260    pub fn same_site(mut self, same_site: SameSite) -> Self {
261        self.same_site = same_site;
262        self
263    }
264
265    /// Set the cookie `Path`. Default: `"/"`.
266    pub fn path(mut self, path: &str) -> Self {
267        self.path = path.to_owned();
268        self
269    }
270
271    /// Set `Max-Age` in seconds. If not set, the cookie is a session cookie
272    /// (deleted when the browser closes).
273    pub fn max_age(mut self, secs: u64) -> Self {
274        self.max_age = Some(secs);
275        self
276    }
277
278    /// Set the `Domain` attribute. If not set, the cookie defaults to the
279    /// origin domain (no subdomain sharing).
280    pub fn domain(mut self, domain: &str) -> Self {
281        self.domain = Some(domain.to_owned());
282        self
283    }
284
285    /// Queue the cookie on the outgoing response.
286    pub fn send(self) {
287        let mut header = format!(
288            "{}={}; SameSite={}; Path={}",
289            self.name, self.value, self.same_site, self.path
290        );
291        if self.http_only {
292            header.push_str("; HttpOnly");
293        }
294        if self.secure {
295            header.push_str("; Secure");
296        }
297        if let Some(max_age) = self.max_age {
298            header.push_str(&format!("; Max-Age={max_age}"));
299        }
300        if let Some(ref domain) = self.domain {
301            header.push_str(&format!("; Domain={domain}"));
302        }
303        push_cookie(header);
304    }
305}