Skip to main content

rustauth_redis/
secondary.rs

1use redis::aio::ConnectionManager;
2use redis::AsyncCommands;
3use rustauth_core::error::RustAuthError;
4use rustauth_core::options::{SecondaryStorage, SecondaryStorageFuture};
5
6use crate::url::{secondary_storage_scan_pattern, validate_key_prefix};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct RedisSecondaryStorageOptions {
10    pub key_prefix: String,
11    pub scan_count: u32,
12}
13
14impl Default for RedisSecondaryStorageOptions {
15    fn default() -> Self {
16        Self {
17            key_prefix: "rustauth:".to_owned(),
18            scan_count: 100,
19        }
20    }
21}
22
23#[derive(Clone)]
24pub struct RedisSecondaryStorage {
25    manager: ConnectionManager,
26    options: RedisSecondaryStorageOptions,
27}
28
29impl RedisSecondaryStorage {
30    pub async fn connect(redis_url: &str) -> Result<Self, RustAuthError> {
31        Self::connect_with_options(redis_url, RedisSecondaryStorageOptions::default()).await
32    }
33
34    pub async fn connect_with_options(
35        redis_url: &str,
36        options: RedisSecondaryStorageOptions,
37    ) -> Result<Self, RustAuthError> {
38        let manager = crate::connect_manager(redis_url).await?;
39        Ok(Self::new(manager, options))
40    }
41
42    pub fn new(manager: ConnectionManager, options: RedisSecondaryStorageOptions) -> Self {
43        Self { manager, options }
44    }
45
46    pub async fn list_keys(&self) -> Result<Vec<String>, RustAuthError> {
47        validate_secondary_storage_options(&self.options)?;
48        let secondary_prefix = self.secondary_prefix();
49        let pattern = secondary_storage_scan_pattern(&secondary_prefix);
50        let physical_keys = scan_keys(&self.manager, &pattern, self.options.scan_count).await?;
51        let mut keys = Vec::new();
52        for key in physical_keys {
53            if let Some(unprefixed) = key.strip_prefix(secondary_prefix.as_str()) {
54                keys.push(unprefixed.to_owned());
55            }
56        }
57        Ok(keys)
58    }
59
60    pub async fn clear(&self) -> Result<(), RustAuthError> {
61        let keys = self
62            .list_keys()
63            .await?
64            .into_iter()
65            .map(|key| self.prefixed_key(&key))
66            .collect::<Result<Vec<_>, _>>()?;
67        if keys.is_empty() {
68            return Ok(());
69        }
70        let mut manager = self.manager.clone();
71        let _: usize = manager
72            .del(keys)
73            .await
74            .map_err(|error| RustAuthError::Adapter(error.to_string()))?;
75        Ok(())
76    }
77
78    fn secondary_prefix(&self) -> String {
79        format!("{}secondary:", self.options.key_prefix)
80    }
81
82    fn prefixed_key(&self, key: &str) -> Result<String, RustAuthError> {
83        validate_key_prefix(&self.options.key_prefix)?;
84        Ok(format!("{}secondary:{key}", self.options.key_prefix))
85    }
86}
87
88impl SecondaryStorage for RedisSecondaryStorage {
89    fn get<'a>(&'a self, key: &'a str) -> SecondaryStorageFuture<'a, Option<String>> {
90        Box::pin(async move {
91            let mut manager = self.manager.clone();
92            manager
93                .get(self.prefixed_key(key)?)
94                .await
95                .map_err(|error| RustAuthError::Adapter(error.to_string()))
96        })
97    }
98
99    fn set<'a>(
100        &'a self,
101        key: &'a str,
102        value: String,
103        ttl_seconds: Option<u64>,
104    ) -> SecondaryStorageFuture<'a, ()> {
105        Box::pin(async move {
106            let redis_key = self.prefixed_key(key)?;
107            let mut manager = self.manager.clone();
108            match ttl_seconds {
109                Some(0) => {
110                    let _: usize = manager
111                        .del(redis_key)
112                        .await
113                        .map_err(|error| RustAuthError::Adapter(error.to_string()))?;
114                }
115                Some(ttl_seconds) => {
116                    let _: () = manager
117                        .set_ex(redis_key, value, ttl_seconds)
118                        .await
119                        .map_err(|error| RustAuthError::Adapter(error.to_string()))?;
120                }
121                None => {
122                    let _: () = manager
123                        .set(redis_key, value)
124                        .await
125                        .map_err(|error| RustAuthError::Adapter(error.to_string()))?;
126                }
127            }
128            Ok(())
129        })
130    }
131
132    fn set_if_not_exists<'a>(
133        &'a self,
134        key: &'a str,
135        value: String,
136        ttl_seconds: Option<u64>,
137    ) -> SecondaryStorageFuture<'a, bool> {
138        Box::pin(async move {
139            let redis_key = self.prefixed_key(key)?;
140            let mut manager = self.manager.clone();
141            if ttl_seconds == Some(0) {
142                return Ok(false);
143            }
144            let mut command = redis::cmd("SET");
145            command.arg(&redis_key).arg(value).arg("NX");
146            if let Some(ttl_seconds) = ttl_seconds {
147                command.arg("EX").arg(ttl_seconds);
148            }
149            let created: Option<String> = command
150                .query_async(&mut manager)
151                .await
152                .map_err(|error| RustAuthError::Adapter(error.to_string()))?;
153            Ok(created.is_some())
154        })
155    }
156
157    fn delete<'a>(&'a self, key: &'a str) -> SecondaryStorageFuture<'a, ()> {
158        Box::pin(async move {
159            let mut manager = self.manager.clone();
160            let _: usize = manager
161                .del(self.prefixed_key(key)?)
162                .await
163                .map_err(|error| RustAuthError::Adapter(error.to_string()))?;
164            Ok(())
165        })
166    }
167
168    fn take<'a>(&'a self, key: &'a str) -> SecondaryStorageFuture<'a, Option<String>> {
169        Box::pin(async move {
170            let mut manager = self.manager.clone();
171            redis::cmd("GETDEL")
172                .arg(self.prefixed_key(key)?)
173                .query_async(&mut manager)
174                .await
175                .map_err(|error| RustAuthError::Adapter(error.to_string()))
176        })
177    }
178
179    fn compare_and_set<'a>(
180        &'a self,
181        key: &'a str,
182        expected: Option<String>,
183        value: String,
184        ttl_seconds: Option<u64>,
185    ) -> SecondaryStorageFuture<'a, bool> {
186        Box::pin(async move {
187            let redis_key = self.prefixed_key(key)?;
188            let mut manager = self.manager.clone();
189            if ttl_seconds == Some(0) {
190                let deleted = self.delete_if_value(key, expected).await?;
191                return Ok(deleted);
192            }
193            let script = r#"
194local current = redis.call("GET", KEYS[1])
195local expected_is_nil = ARGV[1]
196local expected = ARGV[2]
197if expected_is_nil == "1" then
198  if current ~= false then return 0 end
199else
200  if current ~= expected then return 0 end
201end
202if ARGV[4] == "" then
203  redis.call("SET", KEYS[1], ARGV[3])
204else
205  redis.call("SET", KEYS[1], ARGV[3], "EX", tonumber(ARGV[4]))
206end
207return 1
208"#;
209            let expected_is_nil = expected.is_none();
210            let expected = expected.unwrap_or_default();
211            let ttl = ttl_seconds.map(|ttl| ttl.to_string()).unwrap_or_default();
212            let applied: i64 = redis::cmd("EVAL")
213                .arg(script)
214                .arg(1)
215                .arg(redis_key)
216                .arg(if expected_is_nil { "1" } else { "0" })
217                .arg(expected)
218                .arg(value)
219                .arg(ttl)
220                .query_async(&mut manager)
221                .await
222                .map_err(|error| RustAuthError::Adapter(error.to_string()))?;
223            Ok(applied == 1)
224        })
225    }
226
227    fn delete_if_value<'a>(
228        &'a self,
229        key: &'a str,
230        expected: Option<String>,
231    ) -> SecondaryStorageFuture<'a, bool> {
232        Box::pin(async move {
233            let Some(expected) = expected else {
234                return Ok(false);
235            };
236            let redis_key = self.prefixed_key(key)?;
237            let mut manager = self.manager.clone();
238            let script = r#"
239if redis.call("GET", KEYS[1]) == ARGV[1] then
240  redis.call("DEL", KEYS[1])
241  return 1
242end
243return 0
244"#;
245            let deleted: i64 = redis::cmd("EVAL")
246                .arg(script)
247                .arg(1)
248                .arg(redis_key)
249                .arg(expected)
250                .query_async(&mut manager)
251                .await
252                .map_err(|error| RustAuthError::Adapter(error.to_string()))?;
253            Ok(deleted == 1)
254        })
255    }
256}
257
258fn validate_secondary_storage_options(
259    options: &RedisSecondaryStorageOptions,
260) -> Result<(), RustAuthError> {
261    validate_key_prefix(&options.key_prefix)?;
262    if options.scan_count == 0 {
263        return Err(RustAuthError::InvalidConfig(
264            "secondary storage scan count must be greater than zero".to_owned(),
265        ));
266    }
267    Ok(())
268}
269
270async fn scan_keys(
271    manager: &ConnectionManager,
272    pattern: &str,
273    count: u32,
274) -> Result<Vec<String>, RustAuthError> {
275    let mut conn = manager.clone();
276    let mut cursor = 0u64;
277    let mut keys = Vec::new();
278    loop {
279        let (next_cursor, page): (u64, Vec<String>) = redis::cmd("SCAN")
280            .arg(cursor)
281            .arg("MATCH")
282            .arg(pattern)
283            .arg("COUNT")
284            .arg(count)
285            .query_async(&mut conn)
286            .await
287            .map_err(|error| RustAuthError::Adapter(error.to_string()))?;
288        keys.extend(page);
289        if next_cursor == 0 {
290            break;
291        }
292        cursor = next_cursor;
293    }
294    Ok(keys)
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use rustauth_core::error::RustAuthError;
301
302    #[test]
303    fn list_keys_rejects_empty_prefix() {
304        let options = RedisSecondaryStorageOptions {
305            key_prefix: String::new(),
306            scan_count: 100,
307        };
308        assert!(matches!(
309            validate_secondary_storage_options(&options),
310            Err(RustAuthError::InvalidConfig(message))
311                if message == "secondary storage key prefix must not be empty"
312        ));
313    }
314
315    #[test]
316    fn list_keys_rejects_zero_scan_count() {
317        let options = RedisSecondaryStorageOptions {
318            key_prefix: "rustauth:".to_owned(),
319            scan_count: 0,
320        };
321        assert!(matches!(
322            validate_secondary_storage_options(&options),
323            Err(RustAuthError::InvalidConfig(message))
324                if message == "secondary storage scan count must be greater than zero"
325        ));
326    }
327}