Skip to main content

spacetimedb_sdk/
credentials.rs

1//! Utilities for saving and re-using credentials.
2//!
3//! Users are encouraged to import this module by name and refer to its contents by qualified path, like:
4//! ```ignore
5//! use spacetimedb_sdk::credentials;
6//! fn credential_store() -> credentials::File {
7//!     credentials::File::new("my_app")
8//! }
9//! ```
10
11#[cfg(not(feature = "browser"))]
12mod native_mod {
13    use home::home_dir;
14    use spacetimedb_lib::{bsatn, de::Deserialize, ser::Serialize};
15    use std::path::PathBuf;
16    use thiserror::Error;
17
18    const CREDENTIALS_DIR: &str = ".spacetimedb_client_credentials";
19
20    #[derive(Error, Debug)]
21    pub enum CredentialFileError {
22        #[error("Failed to determine user home directory as root for credentials storage")]
23        DetermineHomeDir,
24        #[error("Error creating credential storage directory {path}")]
25        CreateDir {
26            path: PathBuf,
27            #[source]
28            source: std::io::Error,
29        },
30        #[error("Error serializing credentials for storage in file")]
31        Serialize {
32            #[source]
33            source: bsatn::EncodeError,
34        },
35        #[error("Error writing BSATN-serialized credentials to file {path}")]
36        Write {
37            path: PathBuf,
38            #[source]
39            source: std::io::Error,
40        },
41        #[error("Error reading BSATN-serialized credentials from file {path}")]
42        Read {
43            path: PathBuf,
44            #[source]
45            source: std::io::Error,
46        },
47        #[error("Error deserializing credentials from bytes stored in file {path}")]
48        Deserialize {
49            path: PathBuf,
50            #[source]
51            source: bsatn::DecodeError,
52        },
53    }
54
55    /// A file on disk which stores, or can store, a JWT for authenticating with SpacetimeDB.
56    ///
57    /// The file does not necessarily exist or store credentials.
58    /// If the credentials have been stored previously, they can be accessed with [`File::load`].
59    /// New credentials can be saved to disk with [`File::save`].
60    pub struct File {
61        filename: String,
62    }
63
64    #[derive(Serialize, Deserialize)]
65    struct Credentials {
66        token: String,
67    }
68
69    impl File {
70        /// Get a handle on a file which stores a SpacetimeDB [`Identity`] and its private access token.
71        ///
72        /// This method does not create the file or check that it exists.
73        ///
74        /// Distinct applications running as the same user on the same machine
75        /// may share [`Identity`]/token pairs by supplying the same `key`.
76        /// Users who desire distinct credentials for their application
77        /// should supply a unique `key` per application.
78        ///
79        /// No additional namespacing is provided to tie the stored token
80        /// to a particular SpacetimeDB instance or cluster.
81        /// Users who intend to connect to multiple instances or clusters
82        /// should use a distinct `key` per cluster.
83        pub fn new(key: impl Into<String>) -> Self {
84            Self { filename: key.into() }
85        }
86
87        fn determine_home_dir() -> Result<PathBuf, CredentialFileError> {
88            home_dir().ok_or(CredentialFileError::DetermineHomeDir)
89        }
90
91        fn ensure_credentials_dir() -> Result<(), CredentialFileError> {
92            let mut path = Self::determine_home_dir()?;
93            path.push(CREDENTIALS_DIR);
94
95            std::fs::create_dir_all(&path).map_err(|source| CredentialFileError::CreateDir { path, source })
96        }
97
98        fn path(&self) -> Result<PathBuf, CredentialFileError> {
99            let mut path = Self::determine_home_dir()?;
100            path.push(CREDENTIALS_DIR);
101            path.push(&self.filename);
102            Ok(path)
103        }
104
105        /// Store the provided `token` to disk in the file referred to by `self`.
106        ///
107        /// Future calls to [`Self::load`] on a `File` with the same key can retrieve the token.
108        ///
109        /// Expected usage is to call this from a [`super::DbConnectionBuilder::on_connect`] callback.
110        ///
111        /// ```ignore
112        /// DbConnection::builder()
113        ///   .on_connect(|_ctx, _identity, token| {
114        ///       credentials::File::new("my_app").save(token).unwrap();
115        /// })
116        /// ```
117        pub fn save(self, token: impl Into<String>) -> Result<(), CredentialFileError> {
118            Self::ensure_credentials_dir()?;
119
120            let creds = bsatn::to_vec(&Credentials { token: token.into() })
121                .map_err(|source| CredentialFileError::Serialize { source })?;
122            let path = self.path()?;
123            std::fs::write(&path, creds).map_err(|source| CredentialFileError::Write { path, source })?;
124            Ok(())
125        }
126
127        /// Load a saved token from disk in the file referred to by `self`,
128        /// if they have previously been stored by [`Self::save`].
129        ///
130        /// Returns `Err` if I/O fails,
131        /// `None` if credentials have not previously been stored,
132        /// or `Some` if credentials are successfully loaded from disk.
133        /// After unwrapping the `Result`, the returned `Option` can be passed to
134        /// [`super::DbConnectionBuilder::with_token`].
135        ///
136        /// ```ignore
137        /// DbConnection::builder()
138        ///   .with_token(credentials::File::new("my_app").load().unwrap())
139        /// ```
140        pub fn load(self) -> Result<Option<String>, CredentialFileError> {
141            let path = self.path()?;
142
143            let bytes = match std::fs::read(&path) {
144                Ok(bytes) => bytes,
145                Err(e) if matches!(e.kind(), std::io::ErrorKind::NotFound) => return Ok(None),
146                Err(source) => return Err(CredentialFileError::Read { path, source }),
147            };
148
149            let creds = bsatn::from_slice::<Credentials>(&bytes)
150                .map_err(|source| CredentialFileError::Deserialize { path, source })?;
151            Ok(Some(creds.token))
152        }
153    }
154}
155
156#[cfg(feature = "browser")]
157mod web_mod {
158    pub use gloo_storage::{LocalStorage, SessionStorage, Storage};
159
160    pub mod cookies {
161        use thiserror::Error;
162        use wasm_bindgen::{JsCast, JsValue};
163        use web_sys::HtmlDocument;
164
165        #[derive(Error, Debug)]
166        pub enum CookieError {
167            #[error("Error reading cookies: {0:?}")]
168            Get(JsValue),
169
170            #[error("Error setting cookie `{key}`: {js_value:?}")]
171            Set { key: String, js_value: JsValue },
172        }
173
174        /// A builder for constructing and setting cookies.
175        pub struct Cookie {
176            name: String,
177            value: String,
178            path: Option<String>,
179            domain: Option<String>,
180            max_age: Option<i32>,
181            secure: bool,
182            same_site: Option<SameSite>,
183        }
184
185        impl Cookie {
186            /// Creates a new cookie builder with the specified name and value.
187            pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
188                Self {
189                    name: name.into(),
190                    value: value.into(),
191                    path: None,
192                    domain: None,
193                    max_age: None,
194                    secure: false,
195                    same_site: None,
196                }
197            }
198
199            /// Gets the value of a cookie by name.
200            pub fn get(name: &str) -> Result<Option<String>, CookieError> {
201                let doc = get_html_document();
202                let all = doc.cookie().map_err(CookieError::Get)?;
203                for cookie in all.split(';') {
204                    let cookie = cookie.trim();
205                    if let Some((k, v)) = cookie.split_once('=')
206                        && k == name
207                    {
208                        return Ok(Some(v.to_string()));
209                    }
210                }
211
212                Ok(None)
213            }
214
215            /// Sets the `Path` attribute (e.g., "/").
216            pub fn path(mut self, path: impl Into<String>) -> Self {
217                self.path = Some(path.into());
218                self
219            }
220
221            /// Sets the `Domain` attribute (e.g., "example.com").
222            pub fn domain(mut self, domain: impl Into<String>) -> Self {
223                self.domain = Some(domain.into());
224                self
225            }
226
227            /// Sets the `Max-Age` attribute in seconds.
228            pub fn max_age(mut self, seconds: i32) -> Self {
229                self.max_age = Some(seconds);
230                self
231            }
232
233            /// Toggles the `Secure` flag.
234            /// Defaults to `false`.
235            pub fn secure(mut self, enabled: bool) -> Self {
236                self.secure = enabled;
237                self
238            }
239
240            /// Sets the `SameSite` attribute (`Strict`, `Lax`, or `None`).
241            pub fn same_site(mut self, same_site: SameSite) -> Self {
242                self.same_site = Some(same_site);
243                self
244            }
245
246            pub fn set(self) -> Result<(), CookieError> {
247                let doc = get_html_document();
248                let mut parts = vec![format!("{}={}", self.name, self.value)];
249
250                if let Some(path) = self.path {
251                    parts.push(format!("Path={path}"));
252                }
253                if let Some(domain) = self.domain {
254                    parts.push(format!("Domain={domain}"));
255                }
256                if let Some(age) = self.max_age {
257                    parts.push(format!("Max-Age={age}"));
258                }
259                if self.secure {
260                    parts.push("Secure".into());
261                }
262                if let Some(same) = self.same_site {
263                    parts.push(format!("SameSite={same}"));
264                }
265
266                let cookie_str = parts.join("; ");
267                doc.set_cookie(&cookie_str).map_err(|e| CookieError::Set {
268                    key: self.name.clone(),
269                    js_value: e,
270                })
271            }
272
273            /// Deletes the cookie by setting its value to empty and `Max-Age=0`.
274            pub fn delete(self) -> Result<(), CookieError> {
275                self.value("").max_age(0).set()
276            }
277
278            /// Helper to override value for delete
279            fn value(mut self, value: impl Into<String>) -> Self {
280                self.value = value.into();
281                self
282            }
283        }
284
285        /// Controls the `SameSite` attribute for cookies.
286        pub enum SameSite {
287            Strict,
288            Lax,
289            None,
290        }
291
292        impl std::fmt::Display for SameSite {
293            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
294                match self {
295                    SameSite::Strict => f.write_str("Strict"),
296                    SameSite::Lax => f.write_str("Lax"),
297                    SameSite::None => f.write_str("None"),
298                }
299            }
300        }
301
302        fn get_html_document() -> HtmlDocument {
303            gloo_utils::document().unchecked_into::<HtmlDocument>()
304        }
305    }
306}
307
308#[cfg(not(feature = "browser"))]
309pub use native_mod::*;
310
311#[cfg(feature = "browser")]
312pub use web_mod::*;