Skip to main content

arcly_http_identity/
session.rs

1//! Server-side token state: refresh-token rotation, pending MFA challenges, and
2//! the per-user device/session index used for "log out everywhere" and
3//! back-channel revocation (Phase 4).
4
5use std::collections::HashMap;
6use std::sync::RwLock;
7use std::time::{SystemTime, UNIX_EPOCH};
8
9use async_trait::async_trait;
10use serde::{Deserialize, Serialize};
11
12use crate::error::Result;
13
14fn unix_now() -> u64 {
15    SystemTime::now()
16        .duration_since(UNIX_EPOCH)
17        .map(|d| d.as_secs())
18        .unwrap_or(0)
19}
20
21// ─── Refresh token store (rotation) ─────────────────────────────────────────
22
23/// Persists the live `jti` of each refresh token so rotation can atomically
24/// invalidate the old one. Backed by Redis with per-key TTL in production.
25#[async_trait]
26pub trait RefreshStore: Send + Sync + 'static {
27    /// Record that `jti` is a valid refresh token for `user_id`.
28    async fn save(&self, jti: &str, user_id: &str, ttl_secs: u64) -> Result<()>;
29    /// Atomically consume `jti`: return its `user_id` and delete it. `None` if
30    /// unknown / already used / expired (rotation replay → reject + revoke).
31    async fn take(&self, jti: &str) -> Result<Option<String>>;
32    /// Revoke a specific `jti` (logout).
33    async fn revoke(&self, jti: &str) -> Result<()>;
34    /// Revoke every refresh token for a user ("log out everywhere").
35    async fn revoke_all(&self, user_id: &str) -> Result<()>;
36}
37
38// ─── Pending MFA challenge store ────────────────────────────────────────────
39
40/// After a correct password on an MFA-enrolled account, the engine parks a
41/// short-lived challenge and returns its id; the second-factor step redeems it.
42#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
43pub struct PendingChallenge {
44    pub user_id: String,
45    pub expires_at: u64,
46}
47
48#[async_trait]
49pub trait ChallengeStore: Send + Sync + 'static {
50    async fn put(&self, challenge_id: &str, user_id: &str, ttl_secs: u64) -> Result<()>;
51    async fn take(&self, challenge_id: &str) -> Result<Option<PendingChallenge>>;
52}
53
54// ─── Session / device index ─────────────────────────────────────────────────
55
56/// One authenticated session/device row surfaced to the user for review/revoke.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct SessionRecord {
59    pub session_id: String,
60    pub user_id: String,
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub device: Option<String>,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub ip: Option<String>,
65    pub created_at: u64,
66    pub last_seen: u64,
67}
68
69/// Tracks active sessions per user for management + back-channel logout.
70#[async_trait]
71pub trait SessionIndex: Send + Sync + 'static {
72    async fn register(&self, record: SessionRecord) -> Result<()>;
73    async fn list(&self, user_id: &str) -> Result<Vec<SessionRecord>>;
74    async fn revoke(&self, session_id: &str) -> Result<()>;
75    async fn revoke_all(&self, user_id: &str) -> Result<()>;
76}
77
78// ─── In-memory implementations (tests / dev) ────────────────────────────────
79
80#[derive(Default)]
81pub struct MemoryRefreshStore {
82    // jti -> (user_id, expires_at)
83    inner: RwLock<HashMap<String, (String, u64)>>,
84}
85impl MemoryRefreshStore {
86    pub fn new() -> Self {
87        Self::default()
88    }
89}
90#[async_trait]
91impl RefreshStore for MemoryRefreshStore {
92    async fn save(&self, jti: &str, user_id: &str, ttl_secs: u64) -> Result<()> {
93        self.inner
94            .write()
95            .unwrap()
96            .insert(jti.to_owned(), (user_id.to_owned(), unix_now() + ttl_secs));
97        Ok(())
98    }
99    async fn take(&self, jti: &str) -> Result<Option<String>> {
100        let removed = self.inner.write().unwrap().remove(jti);
101        Ok(removed.and_then(|(uid, exp)| (unix_now() < exp).then_some(uid)))
102    }
103    async fn revoke(&self, jti: &str) -> Result<()> {
104        self.inner.write().unwrap().remove(jti);
105        Ok(())
106    }
107    async fn revoke_all(&self, user_id: &str) -> Result<()> {
108        self.inner
109            .write()
110            .unwrap()
111            .retain(|_, (uid, _)| uid != user_id);
112        Ok(())
113    }
114}
115
116#[derive(Default)]
117pub struct MemoryChallengeStore {
118    inner: RwLock<HashMap<String, PendingChallenge>>,
119}
120impl MemoryChallengeStore {
121    pub fn new() -> Self {
122        Self::default()
123    }
124}
125#[async_trait]
126impl ChallengeStore for MemoryChallengeStore {
127    async fn put(&self, challenge_id: &str, user_id: &str, ttl_secs: u64) -> Result<()> {
128        self.inner.write().unwrap().insert(
129            challenge_id.to_owned(),
130            PendingChallenge {
131                user_id: user_id.to_owned(),
132                expires_at: unix_now() + ttl_secs,
133            },
134        );
135        Ok(())
136    }
137    async fn take(&self, challenge_id: &str) -> Result<Option<PendingChallenge>> {
138        let taken = self.inner.write().unwrap().remove(challenge_id);
139        Ok(taken.filter(|c| unix_now() < c.expires_at))
140    }
141}
142
143#[derive(Default)]
144pub struct MemorySessionIndex {
145    inner: RwLock<HashMap<String, SessionRecord>>,
146}
147impl MemorySessionIndex {
148    pub fn new() -> Self {
149        Self::default()
150    }
151}
152#[async_trait]
153impl SessionIndex for MemorySessionIndex {
154    async fn register(&self, record: SessionRecord) -> Result<()> {
155        self.inner
156            .write()
157            .unwrap()
158            .insert(record.session_id.clone(), record);
159        Ok(())
160    }
161    async fn list(&self, user_id: &str) -> Result<Vec<SessionRecord>> {
162        Ok(self
163            .inner
164            .read()
165            .unwrap()
166            .values()
167            .filter(|r| r.user_id == user_id)
168            .cloned()
169            .collect())
170    }
171    async fn revoke(&self, session_id: &str) -> Result<()> {
172        self.inner.write().unwrap().remove(session_id);
173        Ok(())
174    }
175    async fn revoke_all(&self, user_id: &str) -> Result<()> {
176        self.inner
177            .write()
178            .unwrap()
179            .retain(|_, r| r.user_id != user_id);
180        Ok(())
181    }
182}