Skip to main content

featherbit/plugins/util/
cookie_session.rs

1//! Encrypted client-side session cookies.
2//!
3//! The primitive that lets stateless SSO plugins (`openid-connect`,
4//! `cas-auth`, `authz-casdoor`) run interactive login flows without any
5//! server-side session store: the session payload (tokens, claims, or the
6//! transient auth-flow state) is sealed into an authenticated, encrypted
7//! cookie with an embedded expiry. Any gateway instance sharing the signing
8//! secret can open any cookie, so this works across a horizontally-scaled
9//! deployment with no coordination.
10//!
11//! Sealing uses AES-256-GCM (via `ring`) with a per-message random nonce; the
12//! 256-bit key is derived from the configured secret by SHA-256, so a secret
13//! of any length is accepted. The expiry is part of the authenticated
14//! plaintext, so it cannot be tampered with.
15//!
16//! Trade-offs inherent to client-side sessions (shared with APISIX's default
17//! cookie sessions): there is no cheap server-side revocation before expiry
18//! (use short lifetimes), and cookies are capped near 4 KB, so only essential
19//! data should be stored.
20
21use std::time::{Duration, SystemTime, UNIX_EPOCH};
22
23use base64::engine::general_purpose::URL_SAFE_NO_PAD;
24use base64::Engine;
25use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM, NONCE_LEN};
26use ring::digest::{digest, SHA256};
27use ring::rand::{SecureRandom, SystemRandom};
28
29/// Failure opening a sealed cookie. Every variant means "treat the request as
30/// unauthenticated" — callers should never distinguish these to a client.
31#[derive(Debug, PartialEq)]
32pub enum CookieError {
33    /// The value is not valid base64url or is too short to contain a nonce.
34    Malformed,
35    /// Authentication/decryption failed (wrong key or tampered value).
36    BadSeal,
37    /// The sealed payload's expiry is in the past.
38    Expired,
39}
40
41impl std::fmt::Display for CookieError {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        let s = match self {
44            Self::Malformed => "malformed cookie",
45            Self::BadSeal => "cookie failed authentication",
46            Self::Expired => "cookie expired",
47        };
48        f.write_str(s)
49    }
50}
51
52/// Seals and opens encrypted session cookies with a fixed derived key.
53pub struct CookieSealer {
54    key_bytes: [u8; 32],
55    rng: SystemRandom,
56}
57
58impl CookieSealer {
59    /// Derives the AES-256 key from `secret` (SHA-256), so any-length secrets
60    /// work. The same secret must be configured on every gateway instance.
61    pub fn new(secret: &str) -> Self {
62        let d = digest(&SHA256, secret.as_bytes());
63        let mut key_bytes = [0u8; 32];
64        key_bytes.copy_from_slice(d.as_ref());
65        Self {
66            key_bytes,
67            rng: SystemRandom::new(),
68        }
69    }
70
71    fn key(&self) -> LessSafeKey {
72        LessSafeKey::new(
73            UnboundKey::new(&AES_256_GCM, &self.key_bytes)
74                .expect("AES-256-GCM key from 32 bytes is always valid"),
75        )
76    }
77
78    /// Seals `payload` into a cookie value that expires after `ttl`.
79    ///
80    /// Layout of the returned base64url string:
81    /// `nonce(12) || AES-256-GCM(expiry_be_u64(8) || payload)`. The expiry is
82    /// authenticated, so an attacker cannot extend a session.
83    pub fn seal(&self, payload: &[u8], ttl: Duration) -> String {
84        let expiry = now_unix().saturating_add(ttl.as_secs());
85
86        let mut plaintext = Vec::with_capacity(8 + payload.len());
87        plaintext.extend_from_slice(&expiry.to_be_bytes());
88        plaintext.extend_from_slice(payload);
89
90        let mut nonce_bytes = [0u8; NONCE_LEN];
91        self.rng
92            .fill(&mut nonce_bytes)
93            .expect("system RNG must produce a nonce");
94        let nonce = Nonce::assume_unique_for_key(nonce_bytes);
95
96        self.key()
97            .seal_in_place_append_tag(nonce, Aad::empty(), &mut plaintext)
98            .expect("sealing never fails for a valid key/nonce");
99
100        let mut out = Vec::with_capacity(NONCE_LEN + plaintext.len());
101        out.extend_from_slice(&nonce_bytes);
102        out.extend_from_slice(&plaintext);
103        URL_SAFE_NO_PAD.encode(out)
104    }
105
106    /// Opens a sealed cookie value, returning the original payload.
107    ///
108    /// Fails with [`CookieError`] if the value is malformed, authentication
109    /// fails (wrong key or tampering), or the embedded expiry has passed.
110    pub fn open(&self, value: &str) -> Result<Vec<u8>, CookieError> {
111        let raw = URL_SAFE_NO_PAD
112            .decode(value.as_bytes())
113            .map_err(|_| CookieError::Malformed)?;
114        // Need at least nonce + the GCM tag (16) + 8-byte expiry.
115        if raw.len() < NONCE_LEN + 16 + 8 {
116            return Err(CookieError::Malformed);
117        }
118        let (nonce_bytes, sealed) = raw.split_at(NONCE_LEN);
119        let mut in_out = sealed.to_vec();
120        let nonce =
121            Nonce::try_assume_unique_for_key(nonce_bytes).map_err(|_| CookieError::Malformed)?;
122
123        let plaintext = self
124            .key()
125            .open_in_place(nonce, Aad::empty(), &mut in_out)
126            .map_err(|_| CookieError::BadSeal)?;
127
128        if plaintext.len() < 8 {
129            return Err(CookieError::BadSeal);
130        }
131        let mut expiry_bytes = [0u8; 8];
132        expiry_bytes.copy_from_slice(&plaintext[..8]);
133        let expiry = u64::from_be_bytes(expiry_bytes);
134        if now_unix() > expiry {
135            return Err(CookieError::Expired);
136        }
137        Ok(plaintext[8..].to_vec())
138    }
139}
140
141/// `SameSite` attribute for a `Set-Cookie` header.
142// Full attribute set for completeness; the SSO flows currently only emit `Lax`.
143#[allow(dead_code)]
144#[derive(Debug, Clone, Copy)]
145pub enum SameSite {
146    Strict,
147    Lax,
148    None,
149}
150
151impl SameSite {
152    fn as_str(&self) -> &'static str {
153        match self {
154            Self::Strict => "Strict",
155            Self::Lax => "Lax",
156            Self::None => "None",
157        }
158    }
159}
160
161/// Attributes for building a `Set-Cookie` header value.
162pub struct CookieAttrs<'a> {
163    pub path: &'a str,
164    /// `Max-Age` in seconds; `None` omits it (session cookie), `Some(0)`
165    /// deletes the cookie.
166    pub max_age: Option<u64>,
167    pub http_only: bool,
168    pub secure: bool,
169    pub same_site: SameSite,
170}
171
172impl Default for CookieAttrs<'_> {
173    fn default() -> Self {
174        Self {
175            path: "/",
176            max_age: None,
177            http_only: true,
178            secure: true,
179            same_site: SameSite::Lax,
180        }
181    }
182}
183
184/// Builds a `Set-Cookie` header value for `name=value` with `attrs`.
185pub fn build_set_cookie(name: &str, value: &str, attrs: &CookieAttrs) -> String {
186    let mut s = format!("{}={}; Path={}", name, value, attrs.path);
187    if let Some(max_age) = attrs.max_age {
188        s.push_str(&format!("; Max-Age={}", max_age));
189    }
190    if attrs.http_only {
191        s.push_str("; HttpOnly");
192    }
193    if attrs.secure {
194        s.push_str("; Secure");
195    }
196    s.push_str(&format!("; SameSite={}", attrs.same_site.as_str()));
197    s
198}
199
200/// Whether a cookie scoped to `cookie_path` is sent by the browser on a request
201/// to `request_path` (RFC 6265 §5.1.4 path-match): an exact match, or
202/// `cookie_path` is a prefix ending at a `/` boundary. `/` (or empty) covers
203/// everything.
204///
205/// Interactive-login plugins use this to reject a `session.cookie.path` that
206/// would starve their OAuth callback of the session/flow cookie (which loops
207/// login forever).
208pub fn path_covers(cookie_path: &str, request_path: &str) -> bool {
209    let cp = cookie_path.trim_end_matches('/');
210    if cp.is_empty() {
211        return true;
212    }
213    request_path == cp || request_path.starts_with(&format!("{}/", cp))
214}
215
216/// Reads a named cookie from a `Cookie` header value (`a=1; b=2`).
217pub fn read_cookie<'a>(cookie_header: &'a str, name: &str) -> Option<&'a str> {
218    for pair in cookie_header.split(';') {
219        let pair = pair.trim();
220        if let Some((k, v)) = pair.split_once('=') {
221            if k == name {
222                return Some(v);
223            }
224        }
225    }
226    None
227}
228
229/// A `Max-Age=0` deletion cookie for `name`.
230pub fn delete_cookie(name: &str, path: &str) -> String {
231    build_set_cookie(
232        name,
233        "",
234        &CookieAttrs {
235            path,
236            max_age: Some(0),
237            ..Default::default()
238        },
239    )
240}
241
242fn now_unix() -> u64 {
243    SystemTime::now()
244        .duration_since(UNIX_EPOCH)
245        .map(|d| d.as_secs())
246        .unwrap_or(0)
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn test_seal_open_round_trip() {
255        let sealer = CookieSealer::new("my-signing-secret");
256        let payload = br#"{"sub":"alice","tok":"xyz"}"#;
257        let cookie = sealer.seal(payload, Duration::from_secs(3600));
258        assert_eq!(sealer.open(&cookie).unwrap(), payload);
259    }
260
261    #[test]
262    fn test_wrong_key_fails() {
263        let a = CookieSealer::new("secret-a");
264        let b = CookieSealer::new("secret-b");
265        let cookie = a.seal(b"hello", Duration::from_secs(60));
266        assert_eq!(b.open(&cookie), Err(CookieError::BadSeal));
267    }
268
269    #[test]
270    fn test_tamper_fails() {
271        let sealer = CookieSealer::new("k");
272        let mut cookie = sealer.seal(b"hello", Duration::from_secs(60));
273        // Flip a character in the middle of the ciphertext.
274        let mid = cookie.len() / 2;
275        let ch = cookie.as_bytes()[mid];
276        let replacement = if ch == b'A' { 'B' } else { 'A' };
277        cookie.replace_range(mid..mid + 1, &replacement.to_string());
278        assert!(matches!(
279            sealer.open(&cookie),
280            Err(CookieError::BadSeal) | Err(CookieError::Malformed)
281        ));
282    }
283
284    #[test]
285    fn test_expiry_enforced() {
286        let sealer = CookieSealer::new("k");
287        let cookie = sealer.seal(b"hello", Duration::from_secs(0));
288        // ttl 0 → expiry == now; a moment later it is in the past.
289        std::thread::sleep(Duration::from_millis(1100));
290        assert_eq!(sealer.open(&cookie), Err(CookieError::Expired));
291    }
292
293    #[test]
294    fn test_malformed() {
295        let sealer = CookieSealer::new("k");
296        assert_eq!(sealer.open("not base64!!!"), Err(CookieError::Malformed));
297        assert_eq!(sealer.open("aGVsbG8"), Err(CookieError::Malformed)); // too short
298    }
299
300    #[test]
301    fn test_path_covers() {
302        assert!(path_covers("/", "/anything"));
303        assert!(path_covers("", "/anything"));
304        assert!(path_covers("/app_a", "/app_a"));
305        assert!(path_covers("/app_a", "/app_a/callback"));
306        assert!(path_covers("/app_a/", "/app_a/callback"));
307        // Prefix that is not a path boundary must NOT match.
308        assert!(!path_covers("/app_a", "/app_ab"));
309        assert!(!path_covers("/app_a", "/app_b/callback"));
310    }
311
312    #[test]
313    fn test_read_cookie() {
314        assert_eq!(read_cookie("a=1; session=xyz; b=2", "session"), Some("xyz"));
315        assert_eq!(read_cookie("a=1", "session"), None);
316        assert_eq!(read_cookie("session=abc", "session"), Some("abc"));
317    }
318
319    #[test]
320    fn test_build_set_cookie() {
321        let c = build_set_cookie(
322            "session",
323            "val",
324            &CookieAttrs {
325                path: "/app",
326                max_age: Some(3600),
327                http_only: true,
328                secure: true,
329                same_site: SameSite::Lax,
330            },
331        );
332        assert_eq!(
333            c,
334            "session=val; Path=/app; Max-Age=3600; HttpOnly; Secure; SameSite=Lax"
335        );
336        assert_eq!(
337            delete_cookie("session", "/"),
338            "session=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax"
339        );
340    }
341}