Skip to main content

tauri_plugin_topcoat/
session.rs

1//! Session tokens held in this process instead of in the webview.
2//!
3//! [`TokenStore`] is topcoat's seam for deciding where a session token lives
4//! between requests, and this is the implementation that keeps it here rather
5//! than in a cookie WebKit would throw away. Why that is worth doing, and what
6//! it costs, is argued once on [`Builder::sessions`](crate::Builder::sessions).
7//!
8//! # The two invariants
9//!
10//! Both live in `Webviews::read`, and both fail closed.
11//!
12//! **A token goes only to the webview it was issued to.** The map is keyed by
13//! the label Tauri puts on every protocol request, which is the one identifier
14//! the shell knows for certain, as against a header the webview may or may not
15//! have sent.
16//!
17//! **A token goes only to a webview showing one of our own documents.**
18//! Navigation confinement normally makes the alternative unreachable, but an
19//! application can turn confinement off, so this does not assume it is on.
20
21use std::{
22    collections::HashMap,
23    sync::{Mutex, PoisonError},
24    time::{Duration, Instant},
25};
26
27use topcoat::{
28    context::Cx,
29    router::extensions,
30    session::{Token, TokenStore, TokenStoreFuture},
31};
32
33use crate::trace::{self, Withheld};
34
35/// Which webview a request came from, carried where the webview cannot reach.
36///
37/// An `http::Extensions` entry is process-side data with no wire representation,
38/// so a document inside the webview cannot supply, forge or observe one. The
39/// protocol handler overwrites it on every request.
40#[derive(Debug, Clone)]
41struct RequestWebview(String);
42
43/// What the shell knows about each webview: where it has been, and what it
44/// holds.
45#[derive(Debug)]
46pub(crate) struct Webviews {
47    state: Mutex<HashMap<String, Webview>>,
48}
49
50#[derive(Debug, Default)]
51struct Webview {
52    /// Whether the last navigation observed for this webview was to our own
53    /// origin. `false` until one is, so a webview nobody has watched is not
54    /// handed a token.
55    showing_ours: bool,
56    held: Option<Held>,
57}
58
59#[derive(Debug)]
60struct Held {
61    token: Token,
62    /// `None` when the lifetime could not be added to the current instant,
63    /// which is a lifetime long enough that the process will not outlive it.
64    expires: Option<Instant>,
65}
66
67impl Webviews {
68    pub(crate) fn new() -> Webviews {
69        Webviews {
70            state: Mutex::new(HashMap::new()),
71        }
72    }
73
74    /// Records whether a webview is now showing one of our documents.
75    pub(crate) fn observe(&self, label: &str, ours: bool) {
76        let mut state = self.lock();
77        let webview = state.entry(label.to_owned()).or_default();
78        webview.showing_ours = ours;
79        // The token stays: what changes is only whether it is handed out while
80        // the webview is away.
81    }
82
83    /// The token to present for this request.
84    ///
85    /// Names the rule that declined rather than returning a bare `None`: all
86    /// four fail closed and look identical from outside, where each is just a
87    /// request that was not signed in.
88    fn read(&self, label: &str, now: Instant) -> Result<Token, Withheld> {
89        let state = self.lock();
90        let webview = state.get(label).ok_or(Withheld::UnknownWebview)?;
91        if !webview.showing_ours {
92            return Err(Withheld::ShowingAnotherOrigin);
93        }
94        let held = webview.held.as_ref().ok_or(Withheld::NoToken)?;
95        if held.expires.is_some_and(|expires| now >= expires) {
96            return Err(Withheld::Expired);
97        }
98        Ok(held.token.clone())
99    }
100
101    fn write(&self, label: &str, token: Token, max_age: Duration, now: Instant) {
102        let held = Held {
103            token,
104            expires: now.checked_add(max_age),
105        };
106        self.lock().entry(label.to_owned()).or_default().held = Some(held);
107    }
108
109    fn delete(&self, label: &str) {
110        if let Some(webview) = self.lock().get_mut(label) {
111            webview.held = None;
112        }
113    }
114
115    /// A poisoned map is recovered rather than propagated: every mutation here
116    /// is a single field assignment, so the contents are sound, and logging a
117    /// window out because an unrelated task panicked would be its own bug.
118    fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, Webview>> {
119        self.state.lock().unwrap_or_else(PoisonError::into_inner)
120    }
121}
122
123/// The [`TokenStore`] that keeps the token in this process.
124///
125/// Installed by [`Builder::sessions`](crate::Builder::sessions), which is the
126/// only way to obtain one: the store and the shell have to be looking at the
127/// same state, and letting an application wire that up itself is an invitation
128/// to wire it up wrongly.
129#[derive(Debug)]
130pub(crate) struct WebviewTokenStore {
131    webviews: std::sync::Arc<Webviews>,
132}
133
134impl WebviewTokenStore {
135    pub(crate) const fn new(webviews: std::sync::Arc<Webviews>) -> WebviewTokenStore {
136        WebviewTokenStore { webviews }
137    }
138}
139
140/// Names the requesting webview on a request bound for the router.
141///
142/// Unconditional, so a value from anywhere else is replaced rather than
143/// trusted. Free rather than a method on [`Webviews`], because naming the
144/// asker needs nothing the map knows - only [`requesting_webview`], its one
145/// reader, does.
146pub(crate) fn attach<B>(mut request: http::Request<B>, label: &str) -> http::Request<B> {
147    request
148        .extensions_mut()
149        .insert(RequestWebview(label.to_owned()));
150    request
151}
152
153/// The webview a request came from, as the protocol handler named it.
154///
155/// `None` means the request did not come through this plugin, which the shell
156/// makes unreachable and which is therefore treated as "no session" rather than
157/// guessed at.
158fn requesting_webview(cx: &Cx) -> Option<&str> {
159    extensions(cx)
160        .get::<RequestWebview>()
161        .map(|webview| webview.0.as_str())
162}
163
164impl TokenStore for WebviewTokenStore {
165    fn read<'a>(&'a self, cx: &'a Cx) -> TokenStoreFuture<'a, Option<Token>> {
166        // Resolved before the future is built, so the lock is never held across
167        // a yield point.
168        let token =
169            requesting_webview(cx).and_then(|label| match self.webviews.read(label, now()) {
170                Ok(token) => {
171                    trace::session_presented(label);
172                    Some(token)
173                }
174                Err(withheld) => {
175                    trace::session_withheld(label, withheld);
176                    None
177                }
178            });
179        Box::pin(async move { Ok(token) })
180    }
181
182    fn write<'a>(
183        &'a self,
184        cx: &'a Cx,
185        token: Token,
186        max_age: Duration,
187    ) -> TokenStoreFuture<'a, ()> {
188        if let Some(label) = requesting_webview(cx) {
189            self.webviews.write(label, token, max_age, now());
190            trace::session_issued(label);
191        }
192        Box::pin(async move { Ok(()) })
193    }
194
195    fn delete<'a>(&'a self, cx: &'a Cx) -> TokenStoreFuture<'a, ()> {
196        if let Some(label) = requesting_webview(cx) {
197            self.webviews.delete(label);
198            trace::session_cleared(label);
199        }
200        Box::pin(async move { Ok(()) })
201    }
202}
203
204/// The monotonic clock, and the only one in this crate. Every rule that depends
205/// on time takes the instant as a value.
206fn now() -> Instant {
207    Instant::now()
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    const LABEL: &str = "main";
215
216    fn webviews_showing_ours() -> Webviews {
217        let webviews = Webviews::new();
218        webviews.observe(LABEL, true);
219        webviews
220    }
221
222    fn bytes(token: &Token) -> [u8; 32] {
223        *token.dangerous_as_array()
224    }
225
226    #[test]
227    fn a_written_token_reads_back() {
228        let webviews = webviews_showing_ours();
229        let token = Token::random();
230        let now = Instant::now();
231        webviews.write(LABEL, token.clone(), Duration::from_secs(60), now);
232
233        let read = webviews
234            .read(LABEL, now)
235            .expect("the token was just written");
236        assert_eq!(bytes(&read), bytes(&token));
237    }
238
239    /// By name, not by absence: a test checking only for absence passes while
240    /// the wrong rule does the work.
241    #[track_caller]
242    fn assert_withheld(result: Result<Token, Withheld>, expected: Withheld) {
243        match result {
244            Err(actual) => assert_eq!(actual, expected, "the wrong rule declined"),
245            Ok(_) => panic!("handed over the token, expected {expected:?}"),
246        }
247    }
248
249    #[test]
250    fn a_webview_with_no_token_reads_none() {
251        let webviews = webviews_showing_ours();
252        assert_withheld(webviews.read(LABEL, Instant::now()), Withheld::NoToken);
253    }
254
255    #[test]
256    fn one_webviews_token_is_not_anothers() {
257        let webviews = webviews_showing_ours();
258        webviews.observe("second", true);
259        let now = Instant::now();
260        webviews.write(LABEL, Token::random(), Duration::from_secs(60), now);
261
262        assert_withheld(webviews.read("second", now), Withheld::NoToken);
263    }
264
265    #[test]
266    fn a_webview_showing_a_foreign_document_is_not_handed_the_token() {
267        let webviews = webviews_showing_ours();
268        let now = Instant::now();
269        webviews.write(LABEL, Token::random(), Duration::from_secs(60), now);
270        assert!(webviews.read(LABEL, now).is_ok());
271
272        webviews.observe(LABEL, false);
273        assert_withheld(webviews.read(LABEL, now), Withheld::ShowingAnotherOrigin);
274
275        webviews.observe(LABEL, true);
276        assert!(webviews.read(LABEL, now).is_ok());
277    }
278
279    #[test]
280    fn a_webview_nobody_has_watched_is_not_handed_the_token() {
281        let webviews = Webviews::new();
282        let now = Instant::now();
283        webviews.write(LABEL, Token::random(), Duration::from_secs(60), now);
284        assert_withheld(webviews.read(LABEL, now), Withheld::ShowingAnotherOrigin);
285    }
286
287    #[test]
288    fn an_expired_token_reads_none() {
289        let webviews = webviews_showing_ours();
290        let now = Instant::now();
291        webviews.write(LABEL, Token::random(), Duration::from_secs(60), now);
292
293        let later = now + Duration::from_secs(61);
294        assert_withheld(webviews.read(LABEL, later), Withheld::Expired);
295    }
296
297    #[test]
298    fn a_token_expires_at_its_deadline_rather_than_after_it() {
299        let webviews = webviews_showing_ours();
300        let now = Instant::now();
301        let lifetime = Duration::from_secs(60);
302        webviews.write(LABEL, Token::random(), lifetime, now);
303
304        assert_withheld(webviews.read(LABEL, now + lifetime), Withheld::Expired);
305        assert!(
306            webviews
307                .read(LABEL, now + lifetime - Duration::from_nanos(1))
308                .is_ok()
309        );
310    }
311
312    #[test]
313    fn deleting_discards_the_token_and_keeps_the_webview() {
314        let webviews = webviews_showing_ours();
315        let now = Instant::now();
316        webviews.write(LABEL, Token::random(), Duration::from_secs(60), now);
317        webviews.delete(LABEL);
318
319        assert!(webviews.read(LABEL, now).is_err());
320        webviews.write(LABEL, Token::random(), Duration::from_secs(60), now);
321        assert!(webviews.read(LABEL, now).is_ok());
322    }
323
324    #[test]
325    fn a_rewrite_replaces_the_previous_token() {
326        let webviews = webviews_showing_ours();
327        let now = Instant::now();
328        webviews.write(LABEL, Token::random(), Duration::from_secs(60), now);
329        let rotated = Token::random();
330        webviews.write(LABEL, rotated.clone(), Duration::from_secs(60), now);
331
332        let read = webviews.read(LABEL, now).expect("a token is held");
333        assert_eq!(bytes(&read), bytes(&rotated));
334    }
335
336    #[test]
337    fn attach_overwrites_whatever_was_there() {
338        let mut request = http::Request::new(Vec::<u8>::new());
339        request
340            .extensions_mut()
341            .insert(RequestWebview("forged".to_owned()));
342
343        let request = attach(request, LABEL);
344
345        let attached = request
346            .extensions()
347            .get::<RequestWebview>()
348            .expect("attach inserts one");
349        assert_eq!(attached.0, LABEL);
350    }
351}