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}