Skip to main content

koprogo_api/domain/plateforme/
magic_link.rs

1//! MagicLink — public-access tokens for contractors / external parties.
2//!
3//! Story 3.2 (FR6 INV-13 INV-17). A syndic can issue a magic link to give
4//! temporary, scoped, single-use read access to a ticket / quote / invoice /
5//! contractor-evaluation **without** requiring the recipient to create an
6//! account. Typical use case: a plumber receives an SMS/email link with a
7//! tokenised URL, opens it, sees the relevant ticket, and may submit a
8//! response — all without an authenticated session.
9//!
10//! # Security model
11//!
12//! - The clear token is generated once (32 random bytes, base64url) and **never
13//!   stored**. Only its SHA-256 digest (hex) is persisted.
14//! - Single-use: `consumed_at` is set the first time the token is validated.
15//!   A second validation fails (replay protection).
16//! - Time-bounded: `expires_at` enforces a TTL chosen at issue time.
17//! - Bound to a single scope (`scope_kind` + `scope_id`) — the link cannot be
18//!   reused to access another resource.
19//!
20//! Mirrors the simpler `RefreshToken` pattern but replaces the `revoked: bool`
21//! flag with `consumed_at: Option<DateTime<Utc>>` to enforce single-use.
22
23use crate::application::error::AppError;
24use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
25use chrono::{DateTime, Duration, Utc};
26use serde::{Deserialize, Serialize};
27use sha2::{Digest, Sha256};
28use uuid::Uuid;
29
30/// What kind of resource a MagicLink grants access to.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32pub enum MagicLinkScopeKind {
33    Ticket,
34    Quote,
35    Invoice,
36    ContractorEvaluation,
37    /// Rapport d'intervention prestataire (#835 — absorbe le second système de
38    /// liens magiques qui existait en parallèle, cf. `ContractorReportUseCases`).
39    ContractorReport,
40}
41
42impl std::fmt::Display for MagicLinkScopeKind {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            MagicLinkScopeKind::Ticket => write!(f, "ticket"),
46            MagicLinkScopeKind::Quote => write!(f, "quote"),
47            MagicLinkScopeKind::Invoice => write!(f, "invoice"),
48            MagicLinkScopeKind::ContractorEvaluation => write!(f, "contractor_evaluation"),
49            MagicLinkScopeKind::ContractorReport => write!(f, "contractor_report"),
50        }
51    }
52}
53
54impl std::str::FromStr for MagicLinkScopeKind {
55    type Err = AppError;
56
57    fn from_str(s: &str) -> Result<Self, Self::Err> {
58        match s.to_lowercase().as_str() {
59            "ticket" => Ok(MagicLinkScopeKind::Ticket),
60            "quote" => Ok(MagicLinkScopeKind::Quote),
61            "invoice" => Ok(MagicLinkScopeKind::Invoice),
62            "contractor_evaluation" => Ok(MagicLinkScopeKind::ContractorEvaluation),
63            "contractor_report" => Ok(MagicLinkScopeKind::ContractorReport),
64            other => Err(AppError::Validation(format!(
65                "Invalid magic link scope_kind: {}",
66                other
67            ))),
68        }
69    }
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
73pub struct MagicLink {
74    pub id: Uuid,
75    /// SHA-256 hex of the clear token. The clear token is NEVER stored.
76    pub token_hash: String,
77    /// User to whom the link grants access (e.g. external contractor user).
78    pub subject_user_id: Uuid,
79    pub scope_kind: MagicLinkScopeKind,
80    pub scope_id: Uuid,
81    /// Syndic / superadmin user who issued the link (audit trail).
82    pub issued_by: Uuid,
83    pub expires_at: DateTime<Utc>,
84    pub consumed_at: Option<DateTime<Utc>>,
85    pub created_at: DateTime<Utc>,
86    pub updated_at: DateTime<Utc>,
87}
88
89impl MagicLink {
90    /// Issue a new MagicLink. Returns the persisted entity AND the clear token
91    /// that must be returned in the HTTP response (and never stored elsewhere).
92    ///
93    /// # Errors
94    /// - `AppError::Validation` if `ttl <= 0`, `scope_id` is nil, or
95    ///   `subject_user_id == issued_by`.
96    pub fn issue(
97        subject_user_id: Uuid,
98        scope_kind: MagicLinkScopeKind,
99        scope_id: Uuid,
100        issued_by: Uuid,
101        ttl: Duration,
102    ) -> Result<(Self, String), AppError> {
103        if ttl <= Duration::zero() {
104            return Err(AppError::Validation(
105                "MagicLink ttl must be strictly positive".to_string(),
106            ));
107        }
108        if scope_id.is_nil() {
109            return Err(AppError::Validation(
110                "MagicLink scope_id must not be nil".to_string(),
111            ));
112        }
113        if subject_user_id == issued_by {
114            return Err(AppError::Validation(
115                "MagicLink subject and issuer must differ".to_string(),
116            ));
117        }
118
119        let clear_token = Self::generate_clear_token();
120        let token_hash = Self::hash_token(&clear_token);
121        let now = Utc::now();
122        let expires_at = now + ttl;
123
124        let entity = Self {
125            id: Uuid::new_v4(),
126            token_hash,
127            subject_user_id,
128            scope_kind,
129            scope_id,
130            issued_by,
131            expires_at,
132            consumed_at: None,
133            created_at: now,
134            updated_at: now,
135        };
136
137        Ok((entity, clear_token))
138    }
139
140    /// SHA-256 hex digest of a clear token. Public so the repository / handler
141    /// can hash an incoming token and look it up.
142    pub fn hash_token(clear_token: &str) -> String {
143        let mut hasher = Sha256::new();
144        hasher.update(clear_token.as_bytes());
145        format!("{:x}", hasher.finalize())
146    }
147
148    /// Generate a random 32-byte token, base64url-encoded (no padding).
149    /// Produces a ~43-character URL-safe string with ~256 bits of entropy.
150    fn generate_clear_token() -> String {
151        let mut bytes = [0u8; 32];
152        for b in bytes.iter_mut() {
153            *b = rand::random::<u8>();
154        }
155        URL_SAFE_NO_PAD.encode(bytes)
156    }
157
158    pub fn is_expired(&self) -> bool {
159        Utc::now() > self.expires_at
160    }
161
162    pub fn is_consumed(&self) -> bool {
163        self.consumed_at.is_some()
164    }
165
166    pub fn is_valid(&self) -> bool {
167        !self.is_expired() && !self.is_consumed()
168    }
169
170    /// Mark this link as consumed. Idempotent — subsequent calls are no-ops
171    /// in-memory but should be guarded by the repository's atomic UPDATE.
172    pub fn consume(&mut self) {
173        let now = Utc::now();
174        if self.consumed_at.is_none() {
175            self.consumed_at = Some(now);
176        }
177        self.updated_at = now;
178    }
179}
180
181// ============================================================================
182// Tests — taxonomie 4 catégories obligatoire (CRITICAL.md #3)
183// ============================================================================
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    fn fixture_pair() -> (Uuid, Uuid, Uuid) {
190        (Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4())
191    }
192
193    // ------------------------------------------------------------------------
194    // @happy
195    // ------------------------------------------------------------------------
196
197    #[test]
198    fn happy_issue_returns_valid_pair() {
199        let (subject, issuer, scope) = fixture_pair();
200        let (link, clear) = MagicLink::issue(
201            subject,
202            MagicLinkScopeKind::Ticket,
203            scope,
204            issuer,
205            Duration::days(7),
206        )
207        .expect("issue should succeed for valid inputs");
208
209        assert_eq!(link.subject_user_id, subject);
210        assert_eq!(link.issued_by, issuer);
211        assert_eq!(link.scope_id, scope);
212        assert_eq!(link.scope_kind, MagicLinkScopeKind::Ticket);
213        assert!(link.is_valid());
214        assert!(!link.is_consumed());
215        assert!(!link.is_expired());
216        // Clear token is non-empty, URL-safe length around 43 chars (32 bytes base64url no-pad).
217        assert!(clear.len() >= 40, "clear token too short: {}", clear.len());
218    }
219
220    #[test]
221    fn happy_consume_sets_consumed_at_and_invalidates() {
222        let (subject, issuer, scope) = fixture_pair();
223        let (mut link, _) = MagicLink::issue(
224            subject,
225            MagicLinkScopeKind::Quote,
226            scope,
227            issuer,
228            Duration::hours(1),
229        )
230        .unwrap();
231
232        assert!(link.is_valid());
233        link.consume();
234        assert!(link.is_consumed());
235        assert!(!link.is_valid());
236        assert!(link.consumed_at.is_some());
237    }
238
239    #[test]
240    fn happy_hash_token_is_deterministic_hex64() {
241        let h1 = MagicLink::hash_token("hello");
242        let h2 = MagicLink::hash_token("hello");
243        assert_eq!(h1, h2);
244        assert_eq!(h1.len(), 64); // sha256 hex
245        assert!(h1.chars().all(|c| c.is_ascii_hexdigit()));
246    }
247
248    #[test]
249    fn happy_scope_kind_roundtrips_via_display_and_from_str() {
250        use std::str::FromStr;
251        for kind in [
252            MagicLinkScopeKind::Ticket,
253            MagicLinkScopeKind::Quote,
254            MagicLinkScopeKind::Invoice,
255            MagicLinkScopeKind::ContractorEvaluation,
256            // #835 — le rapport d'intervention rejoint les scopes couverts par
257            // le système générique (absorption du second système de liens).
258            MagicLinkScopeKind::ContractorReport,
259        ] {
260            let s = kind.to_string();
261            let parsed = MagicLinkScopeKind::from_str(&s).expect("roundtrip");
262            assert_eq!(parsed, kind);
263        }
264    }
265
266    #[test]
267    fn happy_contractor_report_scope_kind_string_is_stable() {
268        // Le nom de chaîne est un contrat d'API (utilisé côté front, cf.
269        // MAGIC_LINK_SCOPE_KINDS) — le figer protège contre un renommage
270        // accidentel de variante qui casserait silencieusement l'émission.
271        assert_eq!(
272            MagicLinkScopeKind::ContractorReport.to_string(),
273            "contractor_report"
274        );
275    }
276
277    // ------------------------------------------------------------------------
278    // @edge
279    // ------------------------------------------------------------------------
280
281    #[test]
282    fn edge_token_at_exact_expiry_is_invalid_after_now() {
283        let (subject, issuer, scope) = fixture_pair();
284        let (mut link, _) = MagicLink::issue(
285            subject,
286            MagicLinkScopeKind::Invoice,
287            scope,
288            issuer,
289            Duration::seconds(1),
290        )
291        .unwrap();
292        // Force expiry exactly to 1 second in the past.
293        link.expires_at = Utc::now() - Duration::seconds(1);
294        assert!(link.is_expired());
295        assert!(!link.is_valid());
296    }
297
298    #[test]
299    fn edge_double_consume_is_idempotent() {
300        let (subject, issuer, scope) = fixture_pair();
301        let (mut link, _) = MagicLink::issue(
302            subject,
303            MagicLinkScopeKind::Ticket,
304            scope,
305            issuer,
306            Duration::minutes(5),
307        )
308        .unwrap();
309        link.consume();
310        let first_consumed_at = link.consumed_at.expect("first consume sets timestamp");
311        link.consume();
312        // second consume must not overwrite the original timestamp
313        assert_eq!(link.consumed_at, Some(first_consumed_at));
314    }
315
316    #[test]
317    fn edge_min_ttl_one_second_is_valid_immediately() {
318        let (subject, issuer, scope) = fixture_pair();
319        let (link, _) = MagicLink::issue(
320            subject,
321            MagicLinkScopeKind::ContractorEvaluation,
322            scope,
323            issuer,
324            Duration::seconds(1),
325        )
326        .unwrap();
327        assert!(link.is_valid());
328    }
329
330    // ------------------------------------------------------------------------
331    // @security
332    // ------------------------------------------------------------------------
333
334    #[test]
335    fn security_each_issue_returns_distinct_token_and_hash() {
336        let (subject, issuer, scope) = fixture_pair();
337        let (link_a, clear_a) = MagicLink::issue(
338            subject,
339            MagicLinkScopeKind::Ticket,
340            scope,
341            issuer,
342            Duration::hours(1),
343        )
344        .unwrap();
345        let (link_b, clear_b) = MagicLink::issue(
346            subject,
347            MagicLinkScopeKind::Ticket,
348            scope,
349            issuer,
350            Duration::hours(1),
351        )
352        .unwrap();
353        assert_ne!(clear_a, clear_b, "tokens must be unique per issue");
354        assert_ne!(
355            link_a.token_hash, link_b.token_hash,
356            "hashes must differ since tokens differ"
357        );
358        assert_ne!(link_a.id, link_b.id);
359    }
360
361    #[test]
362    fn security_clear_token_is_never_equal_to_stored_hash() {
363        let (subject, issuer, scope) = fixture_pair();
364        let (link, clear) = MagicLink::issue(
365            subject,
366            MagicLinkScopeKind::Quote,
367            scope,
368            issuer,
369            Duration::hours(1),
370        )
371        .unwrap();
372        assert_ne!(clear, link.token_hash);
373        // And re-hashing the clear token reproduces the stored hash.
374        assert_eq!(MagicLink::hash_token(&clear), link.token_hash);
375    }
376
377    #[test]
378    fn security_contractor_report_link_keeps_its_own_scope_kind() {
379        // Cloisonnement (#835 @security) : un lien émis pour un rapport reste
380        // scopé `ContractorReport` — c'est cette valeur, fixée à l'émission et
381        // jamais réinterprétée, qui empêche un lien Devis d'ouvrir un rapport.
382        let (subject, issuer, scope) = fixture_pair();
383        let (link, _) = MagicLink::issue(
384            subject,
385            MagicLinkScopeKind::ContractorReport,
386            scope,
387            issuer,
388            Duration::hours(72),
389        )
390        .unwrap();
391        assert_eq!(link.scope_kind, MagicLinkScopeKind::ContractorReport);
392        assert_ne!(link.scope_kind, MagicLinkScopeKind::Quote);
393    }
394
395    #[test]
396    fn security_different_inputs_produce_different_hashes() {
397        let h1 = MagicLink::hash_token("token-A");
398        let h2 = MagicLink::hash_token("token-B");
399        assert_ne!(h1, h2);
400    }
401
402    #[test]
403    fn security_expired_link_reports_invalid_even_if_not_consumed() {
404        let (subject, issuer, scope) = fixture_pair();
405        let (mut link, _) = MagicLink::issue(
406            subject,
407            MagicLinkScopeKind::Ticket,
408            scope,
409            issuer,
410            Duration::hours(1),
411        )
412        .unwrap();
413        link.expires_at = Utc::now() - Duration::seconds(10);
414        assert!(!link.is_consumed());
415        assert!(!link.is_valid());
416    }
417
418    // ------------------------------------------------------------------------
419    // @negative
420    // ------------------------------------------------------------------------
421
422    #[test]
423    fn negative_zero_ttl_is_rejected() {
424        let (subject, issuer, scope) = fixture_pair();
425        let err = MagicLink::issue(
426            subject,
427            MagicLinkScopeKind::Ticket,
428            scope,
429            issuer,
430            Duration::zero(),
431        )
432        .unwrap_err();
433        match err {
434            AppError::Validation(_) => {}
435            other => panic!("expected Validation, got {:?}", other),
436        }
437    }
438
439    #[test]
440    fn negative_negative_ttl_is_rejected() {
441        let (subject, issuer, scope) = fixture_pair();
442        let err = MagicLink::issue(
443            subject,
444            MagicLinkScopeKind::Ticket,
445            scope,
446            issuer,
447            Duration::seconds(-1),
448        )
449        .unwrap_err();
450        assert!(matches!(err, AppError::Validation(_)));
451    }
452
453    #[test]
454    fn negative_nil_scope_id_is_rejected() {
455        let (subject, issuer, _) = fixture_pair();
456        let err = MagicLink::issue(
457            subject,
458            MagicLinkScopeKind::Quote,
459            Uuid::nil(),
460            issuer,
461            Duration::hours(1),
462        )
463        .unwrap_err();
464        assert!(matches!(err, AppError::Validation(_)));
465    }
466
467    #[test]
468    fn negative_subject_equals_issuer_is_rejected() {
469        let (subject, _, scope) = fixture_pair();
470        let err = MagicLink::issue(
471            subject,
472            MagicLinkScopeKind::Ticket,
473            scope,
474            subject, // same as subject — syndic can't self-issue
475            Duration::hours(1),
476        )
477        .unwrap_err();
478        assert!(matches!(err, AppError::Validation(_)));
479    }
480
481    #[test]
482    fn negative_invalid_scope_kind_string_is_rejected() {
483        use std::str::FromStr;
484        let err = MagicLinkScopeKind::from_str("payment").unwrap_err();
485        assert!(matches!(err, AppError::Validation(_)));
486    }
487}