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}