Skip to main content

watchword/core/
guard.rs

1use std::string::String;
2use std::time::Duration;
3
4use jiff::Timestamp;
5use tokio::sync::broadcast::error::RecvError;
6use tokio::sync::{Mutex, watch};
7use tokio::task::JoinHandle;
8use tokio::time::{Instant, MissedTickBehavior};
9
10use super::{Auth, AuthEvent};
11use crate::store::{Session, User};
12use crate::types::{Access, UserId, UserInfo};
13
14/// How often a guard re-checks its session in the store, to notice a session
15/// ended by another process.
16const RECHECK: Duration = Duration::from_secs(5 * 60);
17
18impl Auth {
19    /// Watches `session`, signed in as `user` (as loaded for the request,
20    /// such as the `SignedIn` extractor's fields), for a WebSocket's
21    /// `select!` loop:
22    ///
23    /// ```no_run
24    /// # async fn ws(auth: watchword::Auth, user: watchword::store::User, session: watchword::store::Session) {
25    /// let guard = auth.guard(&user, &session);
26    ///
27    /// loop {
28    ///     tokio::select! {
29    ///         _ = guard.ended() => break,
30    ///         info = guard.changed() => {
31    ///             // The user's role, email, login or credential changed.
32    ///         }
33    ///         // ... the socket's own messages
34    ///     }
35    /// }
36    /// # }
37    /// ```
38    ///
39    /// The guard follows [`AuthEvent`]s, sleeps until the session expires,
40    /// and re-checks the session in the store on broadcast lag and every 5
41    /// minutes, so a session deleted by another process still ends the
42    /// socket. It runs a task on the current Tokio runtime until it is
43    /// dropped or the session ends.
44    pub fn guard(&self, user: &User, session: &Session) -> SessionGuard {
45        let (ended_tx, ended) = watch::channel(false);
46        let (changed_tx, changed) = watch::channel(user.info());
47        let current = changed.clone();
48
49        let watcher = Watcher {
50            auth: self.clone(),
51            // Subscribe now, so nothing sent after this call is missed.
52            events: self.events(),
53            session: session.id.clone(),
54            user: user.id,
55            impersonator: session.impersonator,
56            expires_at: session.expires_at,
57            ended: ended_tx,
58            changed: changed_tx,
59        };
60
61        SessionGuard {
62            ended: Mutex::new(ended),
63            changed: Mutex::new(changed),
64            current,
65            task: tokio::spawn(watcher.run()),
66        }
67    }
68}
69
70/// Resolves when a WebSocket's session ends; see [`Auth::guard`].
71///
72/// [`ended`](SessionGuard::ended) and [`changed`](SessionGuard::changed)
73/// take `&self` and are cancel-safe, so they can share one `tokio::select!`.
74///
75/// The guard also keeps the session's user as last seen, which
76/// [`user`](SessionGuard::user) and [`watch_user`](SessionGuard::watch_user)
77/// read without a store lookup, so a socket can gate its own requests on the
78/// user's role:
79///
80/// ```no_run
81/// # async fn ws(auth: watchword::Auth, user: watchword::store::User, session: watchword::store::Session) {
82/// let guard = auth.guard(&user, &session);
83///
84/// // Cloneable, for the app's request handler.
85/// let user = guard.watch_user();
86///
87/// if user.borrow().is_admin() {
88///     // ... answer an admin-only request.
89/// }
90/// # }
91/// ```
92///
93/// That role is current as of the last event or store re-check (on lag and
94/// every 5 minutes), so it may lag a change made by another process. Where
95/// a stale role must not let a request through, use [`Auth::session`], or
96/// `musliweb05::Connection::require_admin` on a musli-web socket, which read
97/// the store.
98pub struct SessionGuard {
99    ended: Mutex<watch::Receiver<bool>>,
100    changed: Mutex<watch::Receiver<UserInfo>>,
101    current: watch::Receiver<UserInfo>,
102    task: JoinHandle<()>,
103}
104
105impl SessionGuard {
106    /// Resolves once the session has ended: signed out, signed out
107    /// everywhere, revoked, the credential changed elsewhere, the user
108    /// deleted, or the session expired; for an impersonated session also
109    /// when its admin is revoked, demoted or deleted. Once ended, it
110    /// resolves at once.
111    pub async fn ended(&self) {
112        let mut ended = self.ended.lock().await;
113        // An error means the watcher is gone, which it only is once ended.
114        let _ = ended.wait_for(|ended| *ended).await;
115    }
116
117    /// Resolves with the user whenever their login, email, role, access or
118    /// credential changes. Changes made while nobody waits are coalesced
119    /// into the latest one. Never resolves once the session has ended.
120    pub async fn changed(&self) -> UserInfo {
121        let mut changed = self.changed.lock().await;
122
123        if changed.changed().await.is_err() {
124            return std::future::pending().await;
125        }
126
127        changed.borrow_and_update().clone()
128    }
129
130    /// The session's user as last seen: as loaded when the guard was made,
131    /// then as each change [`changed`](SessionGuard::changed) reports left
132    /// it. Once the session has ended it is the user as they were last seen
133    /// before. For an impersonated session it is the impersonated user,
134    /// without the [`impersonated_by`](UserInfo::impersonated_by) marker.
135    pub fn user(&self) -> UserInfo {
136        self.current.borrow().clone()
137    }
138
139    /// A receiver of the session's user, as [`user`](SessionGuard::user)
140    /// gives it, for code outside the socket loop, such as the app's request
141    /// handler. It is cheap to clone, `borrow()` reads the latest value, and
142    /// it keeps working after the guard is dropped, holding the last value.
143    /// It is separate from what [`changed`](SessionGuard::changed) tracks:
144    /// reading or waiting on it does not consume a change there.
145    pub fn watch_user(&self) -> watch::Receiver<UserInfo> {
146        self.current.clone()
147    }
148}
149
150impl Drop for SessionGuard {
151    fn drop(&mut self) {
152        self.task.abort();
153    }
154}
155
156struct Watcher {
157    auth: Auth,
158    events: tokio::sync::broadcast::Receiver<AuthEvent>,
159    session: String,
160    user: UserId,
161    impersonator: Option<UserId>,
162    expires_at: Timestamp,
163    ended: watch::Sender<bool>,
164    changed: watch::Sender<UserInfo>,
165}
166
167impl Watcher {
168    async fn run(mut self) {
169        let expiry = tokio::time::sleep(until(self.expires_at));
170        tokio::pin!(expiry);
171
172        let mut recheck = tokio::time::interval_at(Instant::now() + RECHECK, RECHECK);
173        recheck.set_missed_tick_behavior(MissedTickBehavior::Delay);
174
175        // Catches anything that happened between the request loading the
176        // session and the guard subscribing.
177        let mut live = self.recheck().await;
178
179        while live {
180            live = tokio::select! {
181                _ = &mut expiry => false,
182                _ = recheck.tick() => self.recheck().await,
183                event = self.events.recv() => match event {
184                    Ok(event) => self.apply(event),
185                    Err(RecvError::Lagged(_)) => self.recheck().await,
186                    // The service holds the sender, and this task the service.
187                    Err(RecvError::Closed) => false,
188                },
189            };
190        }
191
192        let _ = self.ended.send(true);
193    }
194
195    /// Whether the session is still live after `event`.
196    fn apply(&self, event: AuthEvent) -> bool {
197        match event {
198            AuthEvent::SessionEnded(id) => id != self.session,
199            AuthEvent::UserSessionsEnded { user, keep } => {
200                user != self.user || keep.as_deref() == Some(self.session.as_str())
201            }
202            AuthEvent::UserDeleted(user) => user != self.user && Some(user) != self.impersonator,
203            // An impersonated session ends with its admin's standing.
204            AuthEvent::UserChanged(info) if Some(info.id) == self.impersonator => {
205                info.access == Access::Active && info.is_admin()
206            }
207            AuthEvent::UserChanged(info) if info.id == self.user => {
208                if info.access == Access::Revoked {
209                    return false;
210                }
211
212                self.change(info);
213                true
214            }
215            AuthEvent::UserChanged(_) | AuthEvent::UserCreated(_) => true,
216        }
217    }
218
219    /// Asks the store whether the session is live, passing on a change to
220    /// the user that an event may have been lost for.
221    async fn recheck(&self) -> bool {
222        match self.auth.session(&self.session).await {
223            Ok(Some((user, _))) => {
224                self.change(user.info());
225                true
226            }
227            Ok(None) => false,
228            Err(error) => {
229                // Keep the socket; the next check may succeed.
230                tracing::warn!(error = ?error, "auth: re-checking a socket's session failed");
231                true
232            }
233        }
234    }
235
236    fn change(&self, info: UserInfo) {
237        self.changed.send_if_modified(|current| {
238            if *current == info {
239                return false;
240            }
241
242            *current = info;
243            true
244        });
245    }
246}
247
248/// How long until `at`, or zero if it has passed.
249fn until(at: Timestamp) -> Duration {
250    let left = at.duration_since(Timestamp::now());
251    Duration::try_from(left).unwrap_or(Duration::ZERO)
252}