axum_gate/cookie_template.rs
1//! Secure cookie template builder for authentication cookies.
2//!
3//! This module provides [`CookieTemplate`] for creating secure authentication
4//! cookies with sensible defaults that automatically adjust based on build configuration.
5//! The builder ensures proper security settings while maintaining development ergonomics.
6//!
7//! # Quick Start
8//!
9//! ```rust
10//! use axum_gate::cookie_template::CookieTemplate;
11//! use cookie::time::Duration;
12//!
13//! // Use secure defaults
14//! let template = CookieTemplate::recommended()
15//! .name("auth-token")
16//! .persistent(Duration::hours(24));
17//! ```
18//!
19//! # Security Features
20//!
21//! The builder automatically provides secure defaults:
22//! - **HttpOnly**: Prevents JavaScript access (XSS protection)
23//! - **Secure**: HTTPS-only in production builds
24//! - **SameSite=Strict**: CSRF protection in production
25//! - **Session cookies**: No persistence by default
26//! - **Development-friendly**: Relaxed settings in debug builds for localhost testing
27
28use cookie::time::Duration;
29use cookie::{Cookie, CookieBuilder, SameSite};
30use std::borrow::Cow;
31
32/// Default cookie name used by the gate when none is specified.
33pub const DEFAULT_COOKIE_NAME: &str = "axum-gate";
34
35/// Builder for secure authentication cookies used by `Gate`.
36///
37/// Provides secure defaults that are automatically adjusted based on build configuration:
38/// - **Production builds**: Secure=true, HttpOnly=true, SameSite=Strict, session cookie
39/// - **Debug builds**: Secure=false (for localhost), SameSite=Lax, HttpOnly=true, session cookie
40///
41/// # Security Best Practices
42///
43/// The recommended approach is to start with [`CookieTemplate::recommended()`] and
44/// customize only what you need:
45///
46/// ```rust
47/// use axum_gate::cookie_template::CookieTemplate;
48/// use cookie::{time::Duration, SameSite};
49///
50/// // Secure defaults with custom name and expiration
51/// let template = CookieTemplate::recommended()
52/// .name("auth-token")
53/// .persistent(Duration::hours(24));
54///
55/// // For OAuth/redirect flows that need cross-site navigation
56/// let oauth_template = CookieTemplate::recommended()
57/// .name("oauth-state")
58/// .same_site(SameSite::Lax); // Allow cross-site for redirects
59/// ```
60///
61/// # Security Features
62///
63/// - **HttpOnly**: Prevents JavaScript access to auth cookies (XSS protection)
64/// - **Secure**: HTTPS-only in production (MITM protection)
65/// - **SameSite=Strict**: Prevents CSRF attacks in production
66/// - **Session cookies**: No persistent storage by default (privacy)
67///
68/// # Common Customizations
69///
70/// - `name("my-auth-cookie")` - Set custom cookie name
71/// - `persistent(Duration::hours(24))` - Make cookie persist across browser sessions
72/// - `same_site(SameSite::Lax)` - Allow cross-site navigation (OAuth flows)
73/// - `domain(".example.com")` - Share cookies across subdomains
74///
75/// Convert to `cookie::Cookie` via [`CookieTemplate::builder`] then `.build()`,
76/// or use [`CookieTemplate::validate_and_build`].
77#[derive(Clone, Debug, Eq, PartialEq)]
78pub struct CookieTemplate {
79 name: Cow<'static, str>,
80 value: Cow<'static, str>,
81 path: Cow<'static, str>,
82 domain: Option<Cow<'static, str>>,
83 secure: bool,
84 http_only: bool,
85 same_site: SameSite,
86 max_age: Option<Duration>,
87}
88
89impl Default for CookieTemplate {
90 fn default() -> Self {
91 // In debug (development) builds we relax a couple of flags to improve local ergonomics
92 // (allow http and slightly looser cross-site navigation) while still keeping HttpOnly
93 // and a session-only lifetime. In release we enforce the strict, secure posture.
94 let (secure, same_site) = if cfg!(debug_assertions) {
95 (false, SameSite::Lax)
96 } else {
97 (true, SameSite::Strict)
98 };
99
100 Self {
101 name: Cow::Borrowed(DEFAULT_COOKIE_NAME),
102 value: Cow::Borrowed(""),
103 path: Cow::Borrowed("/"),
104 domain: None,
105 secure,
106 http_only: true,
107 same_site,
108 max_age: None, // session cookie – safer by default
109 }
110 }
111}
112
113impl CookieTemplate {
114 /// Secure recommended defaults.
115 #[must_use]
116 pub fn recommended() -> Self {
117 Self::default()
118 }
119
120 /// Set / override the cookie name.
121 ///
122 /// Keep names short and avoid sensitive info.
123 #[must_use]
124 pub fn name(mut self, name: impl Into<Cow<'static, str>>) -> Self {
125 self.name = name.into();
126 self
127 }
128
129 /// Provide an initial value (normally left empty – the login code will
130 /// insert the JWT).
131 #[must_use]
132 pub fn value(mut self, value: impl Into<Cow<'static, str>>) -> Self {
133 self.value = value.into();
134 self
135 }
136
137 /// Set the cookie path (default `/`).
138 #[must_use]
139 pub fn path(mut self, path: impl Into<Cow<'static, str>>) -> Self {
140 self.path = path.into();
141 self
142 }
143
144 /// Set the cookie domain. Avoid setting for single‑domain apps
145 /// to retain host-only semantics (slightly tighter).
146 #[must_use]
147 pub fn domain(mut self, domain: impl Into<Cow<'static, str>>) -> Self {
148 self.domain = Some(domain.into());
149 self
150 }
151
152 /// Unset the previously configured domain (host-only cookie).
153 #[must_use]
154 pub fn clear_domain(mut self) -> Self {
155 self.domain = None;
156 self
157 }
158
159 /// Explicitly mark the cookie as secure (HTTPS only).
160 #[must_use]
161 pub fn secure(mut self, flag: bool) -> Self {
162 self.secure = flag;
163 self
164 }
165
166 /// Convenience: DISABLE secure flag for local dev ONLY.
167 ///
168 /// In `release` builds this will panic to prevent accidental insecure
169 /// deployment. You must call this intentionally; no environment detection
170 /// is performed here.
171 #[must_use]
172 #[cfg(debug_assertions)]
173 pub fn insecure_dev_only(mut self) -> Self {
174 self.secure = false;
175 self
176 }
177
178 /// Set / unset HttpOnly flag.
179 #[must_use]
180 pub fn http_only(mut self, flag: bool) -> Self {
181 self.http_only = flag;
182 self
183 }
184
185 /// Set the SameSite attribute (default `Strict`).
186 ///
187 /// Consider `Lax` for some OAuth / cross-site redirect flows. Only use
188 /// `None` when you understand the CSRF implications and the need for
189 /// `Secure`.
190 #[must_use]
191 pub fn same_site(mut self, same_site: SameSite) -> Self {
192 self.same_site = same_site;
193 self
194 }
195
196 /// Make persistent with a specific `Max-Age`.
197 #[must_use]
198 pub fn max_age(mut self, max_age: Duration) -> Self {
199 self.max_age = Some(max_age);
200 self
201 }
202
203 /// Remove persistence (session cookie again).
204 #[must_use]
205 pub fn clear_max_age(mut self) -> Self {
206 self.max_age = None;
207 self
208 }
209
210 /// Convenience for setting a persistent cookie lifetime.
211 #[must_use]
212 pub fn persistent(self, duration: Duration) -> Self {
213 self.max_age(duration)
214 }
215
216 /// Use a short-lived cookie (e.g. 15 minutes) – explicit for readability.
217 #[must_use]
218 pub fn short_lived(self) -> Self {
219 self.max_age(Duration::minutes(15))
220 }
221
222 /// Validate the template configuration. Returns `Ok(())` if fine.
223 pub fn validate(&self) -> Result<(), CookieTemplateBuilderError> {
224 if self.same_site == SameSite::None && !self.secure {
225 return Err(CookieTemplateBuilderError::InsecureNoneSameSite);
226 }
227 Ok(())
228 }
229
230 /// Convert into the underlying `cookie::CookieBuilder<'static>`.
231 #[must_use]
232 #[inline]
233 pub fn builder(&self) -> CookieBuilder<'static> {
234 let mut builder = CookieBuilder::new(self.name.clone(), self.value.clone())
235 .secure(self.secure)
236 .http_only(self.http_only)
237 .same_site(self.same_site)
238 .path(self.path.clone());
239
240 if let Some(ref domain) = self.domain {
241 builder = builder.domain(domain.clone());
242 }
243
244 if let Some(max_age) = self.max_age {
245 builder = builder.max_age(max_age);
246 }
247
248 builder
249 }
250
251 /// Validate then build. Returns an error if invalid.
252 pub fn validate_and_build(&self) -> Result<Cookie<'static>, CookieTemplateBuilderError> {
253 self.validate()?;
254 Ok(self.builder().build())
255 }
256
257 /// Build a cookie preserving all template attributes, having the name and value.
258 #[must_use]
259 #[inline]
260 pub fn build_with_name_value(&self, name: &str, value: &str) -> Cookie<'static> {
261 let mut builder = CookieBuilder::new(name.to_owned(), value.to_owned())
262 .secure(self.secure)
263 .http_only(self.http_only)
264 .same_site(self.same_site)
265 .path(self.path.clone());
266
267 if let Some(ref domain) = self.domain {
268 builder = builder.domain(domain.clone());
269 }
270
271 if let Some(max_age) = self.max_age {
272 builder = builder.max_age(max_age);
273 }
274
275 builder.build()
276 }
277
278 /// Build a cookie preserving attributes, overriding only the value.
279 #[must_use]
280 #[inline]
281 pub fn build_with_value(&self, value: &str) -> Cookie<'static> {
282 self.build_with_name_value(self.name.as_ref(), value)
283 }
284
285 /// Build a cookie preserving attributes, overriding only the name.
286 #[must_use]
287 #[inline]
288 pub fn build_with_name(&self, name: &str) -> Cookie<'static> {
289 self.build_with_name_value(name, self.value.as_ref())
290 }
291
292 /// Build a removal cookie preserving attributes, overriding the name.
293 #[must_use]
294 pub fn build_removal(&self) -> Cookie<'static> {
295 let mut cookie = self.builder().build();
296 cookie.make_removal();
297 cookie
298 }
299
300 /// Get a reference to the configured cookie name without allocating.
301 ///
302 /// Prefer this on hot paths (e.g., header extraction).
303 #[must_use]
304 #[inline]
305 pub fn cookie_name_ref(&self) -> &str {
306 self.name.as_ref()
307 }
308}
309
310/// Possible configuration issues detected during validation.
311#[derive(Debug, thiserror::Error)]
312pub enum CookieTemplateBuilderError {
313 #[error("SameSite=None requires Secure=true (browser enforcement & CSRF protection)")]
314 /// SameSite=None requires Secure=true for browser security and CSRF protection.
315 InsecureNoneSameSite,
316}