Skip to main content

koprogo_api/infrastructure/web/
auth_cookie.rs

1//! Refresh-token cookie helpers (WP-FE1 — JWT hors localStorage).
2//!
3//! Le refresh token ne transite plus dans le corps JSON ni dans
4//! `localStorage` (vol de session via XSS). Il est porté par un cookie
5//! `HttpOnly; Secure; SameSite=Strict` scopé sur le chemin des endpoints
6//! d'authentification — illisible par JavaScript, non rejouable hors du
7//! même site. L'access token reste en mémoire JS (header `Bearer`,
8//! inchangé). Couche infra uniquement : les use-cases restent purs.
9
10use actix_web::cookie::{time::Duration, Cookie, SameSite};
11
12/// Nom du cookie portant le refresh token.
13pub const REFRESH_COOKIE_NAME: &str = "koprogo_refresh";
14
15/// Chemin de scoping : le cookie n'est envoyé qu'aux endpoints
16/// `/api/v1/auth/*` (refresh, logout) — surface minimale.
17pub const REFRESH_COOKIE_PATH: &str = "/api/v1/auth";
18
19/// Durée de vie alignée sur le `RefreshToken` domaine (7 jours,
20/// cf. `domain::entities::refresh_token::RefreshToken::new`).
21const REFRESH_COOKIE_MAX_AGE_DAYS: i64 = 7;
22
23/// `Secure` flag : `true` en prod (HTTPS obligatoire), `false` seulement
24/// en dev sur http (sinon le navigateur ignore le cookie). Piloté par
25/// `COOKIE_SECURE` (défaut sûr = `true`).
26fn cookie_secure() -> bool {
27    std::env::var("COOKIE_SECURE")
28        .map(|v| v != "false" && v != "0")
29        .unwrap_or(true)
30}
31
32/// Cookie posant le refresh token (login / refresh-rotation / register /
33/// switch-role). `SameSite=Strict` : front et API servis sur le même site
34/// (Traefik domaine unique) — anti-CSRF natif.
35pub fn build_refresh_cookie(refresh_token: &str) -> Cookie<'static> {
36    Cookie::build(REFRESH_COOKIE_NAME, refresh_token.to_owned())
37        .http_only(true)
38        .secure(cookie_secure())
39        .same_site(SameSite::Strict)
40        .path(REFRESH_COOKIE_PATH)
41        .max_age(Duration::days(REFRESH_COOKIE_MAX_AGE_DAYS))
42        .finish()
43}
44
45/// Cookie d'expiration immédiate (logout) : même nom/chemin, `Max-Age=0`,
46/// valeur vidée. Le navigateur supprime le cookie.
47pub fn build_clearing_cookie() -> Cookie<'static> {
48    Cookie::build(REFRESH_COOKIE_NAME, "")
49        .http_only(true)
50        .secure(cookie_secure())
51        .same_site(SameSite::Strict)
52        .path(REFRESH_COOKIE_PATH)
53        .max_age(Duration::ZERO)
54        .finish()
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60
61    /// @happy — le cookie refresh est HttpOnly, SameSite=Strict, scopé auth.
62    #[test]
63    fn happy_refresh_cookie_has_security_attributes() {
64        let c = build_refresh_cookie("tok-abc");
65        assert_eq!(c.name(), REFRESH_COOKIE_NAME);
66        assert_eq!(c.value(), "tok-abc");
67        assert_eq!(c.http_only(), Some(true));
68        assert_eq!(c.same_site(), Some(SameSite::Strict));
69        assert_eq!(c.path(), Some(REFRESH_COOKIE_PATH));
70        assert_eq!(c.max_age(), Some(Duration::days(7)));
71    }
72
73    /// @security — par défaut (aucune env) le flag Secure est actif :
74    /// jamais de refresh token en clair sur une connexion non chiffrée.
75    #[test]
76    fn security_cookie_secure_defaults_true() {
77        std::env::remove_var("COOKIE_SECURE");
78        assert!(cookie_secure());
79        assert_eq!(build_refresh_cookie("x").secure(), Some(true));
80    }
81
82    /// @edge — opt-out explicite dev uniquement (`COOKIE_SECURE=false`)
83    /// pour http://localhost ; toute autre valeur reste sécurisée.
84    #[test]
85    fn edge_cookie_secure_opt_out_only_explicit_false() {
86        std::env::set_var("COOKIE_SECURE", "false");
87        assert!(!cookie_secure());
88        std::env::set_var("COOKIE_SECURE", "true");
89        assert!(cookie_secure());
90        std::env::set_var("COOKIE_SECURE", "yes-please");
91        assert!(cookie_secure());
92        std::env::remove_var("COOKIE_SECURE");
93    }
94
95    /// @negative — le cookie de logout invalide la session : valeur vidée,
96    /// Max-Age=0 (suppression navigateur), attributs sécurité conservés.
97    #[test]
98    fn negative_clearing_cookie_expires_immediately() {
99        let c = build_clearing_cookie();
100        assert_eq!(c.name(), REFRESH_COOKIE_NAME);
101        assert_eq!(c.value(), "");
102        assert_eq!(c.max_age(), Some(Duration::ZERO));
103        assert_eq!(c.http_only(), Some(true));
104        assert_eq!(c.path(), Some(REFRESH_COOKIE_PATH));
105    }
106}