Skip to main content

dbrest_core/config/
jwt.rs

1//! JWT configuration utilities
2//!
3//! This module provides JSPath parsing for extracting role claims from JWTs.
4
5use compact_str::CompactString;
6
7use super::error::ConfigError;
8
9/// JSON path expression for accessing JWT claims
10///
11/// Used to configure how to extract the role from JWT claims.
12///
13/// # Examples
14///
15/// - `.role` → `[Key("role")]`
16/// - `.realm_access.roles[0]` → `[Key("realm_access"), Key("roles"), Index(0)]`
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum JsPathExp {
19    /// Object key access: `.key`
20    Key(CompactString),
21    /// Array index access: `[0]`
22    Index(usize),
23}
24
25impl JsPathExp {
26    /// Check if this is a key expression
27    pub fn is_key(&self) -> bool {
28        matches!(self, JsPathExp::Key(_))
29    }
30
31    /// Check if this is an index expression
32    pub fn is_index(&self) -> bool {
33        matches!(self, JsPathExp::Index(_))
34    }
35
36    /// Get the key if this is a Key variant
37    pub fn as_key(&self) -> Option<&str> {
38        match self {
39            JsPathExp::Key(k) => Some(k.as_str()),
40            JsPathExp::Index(_) => None,
41        }
42    }
43
44    /// Get the index if this is an Index variant
45    pub fn as_index(&self) -> Option<usize> {
46        match self {
47            JsPathExp::Key(_) => None,
48            JsPathExp::Index(i) => Some(*i),
49        }
50    }
51}
52
53/// Parse a JWT role claim key path
54///
55/// Parses a path expression like `.role` or `.realm_access.roles[0]` into
56/// a list of path segments.
57///
58/// # Examples
59///
60/// ```
61/// use dbrest::config::jwt::{parse_js_path, JsPathExp};
62///
63/// let path = parse_js_path(".role").unwrap();
64/// assert_eq!(path, vec![JsPathExp::Key("role".into())]);
65///
66/// let path = parse_js_path(".realm_access.roles[0]").unwrap();
67/// assert_eq!(path.len(), 3);
68/// ```
69pub fn parse_js_path(input: &str) -> Result<Vec<JsPathExp>, ConfigError> {
70    let mut result = Vec::new();
71    let mut chars = input.chars().peekable();
72
73    while let Some(&c) = chars.peek() {
74        match c {
75            '.' => {
76                chars.next(); // consume '.'
77                let key = parse_key(&mut chars);
78                if !key.is_empty() {
79                    result.push(JsPathExp::Key(key.into()));
80                }
81            }
82            '[' => {
83                chars.next(); // consume '['
84                let index = parse_index(&mut chars)?;
85                result.push(JsPathExp::Index(index));
86            }
87            _ => {
88                // Assume it's a key without leading dot
89                let key = parse_key(&mut chars);
90                if !key.is_empty() {
91                    result.push(JsPathExp::Key(key.into()));
92                }
93            }
94        }
95    }
96
97    if result.is_empty() {
98        // Default to "role" if empty input
99        result.push(JsPathExp::Key("role".into()));
100    }
101
102    Ok(result)
103}
104
105/// Parse a key (identifier) from the input
106fn parse_key(chars: &mut std::iter::Peekable<std::str::Chars>) -> String {
107    let mut key = String::new();
108    while let Some(&c) = chars.peek() {
109        if c == '.' || c == '[' {
110            break;
111        }
112        key.push(c);
113        chars.next();
114    }
115    key
116}
117
118/// Parse an array index from the input
119fn parse_index(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<usize, ConfigError> {
120    let mut num = String::new();
121
122    while let Some(&c) = chars.peek() {
123        if c == ']' {
124            chars.next(); // consume ']'
125            break;
126        }
127        if !c.is_ascii_digit() {
128            return Err(ConfigError::InvalidJsPath(format!(
129                "Invalid character '{}' in array index",
130                c
131            )));
132        }
133        num.push(c);
134        chars.next();
135    }
136
137    if num.is_empty() {
138        return Err(ConfigError::InvalidJsPath("Empty array index".to_string()));
139    }
140
141    num.parse()
142        .map_err(|_| ConfigError::InvalidJsPath(format!("Invalid array index: {}", num)))
143}
144
145/// Extract a value from JSON using a JSPath
146///
147/// # Examples
148///
149/// ```
150/// use dbrest::config::jwt::{parse_js_path, extract_from_json};
151/// use serde_json::json;
152///
153/// let data = json!({
154///     "role": "admin",
155///     "realm_access": {
156///         "roles": ["user", "moderator"]
157///     }
158/// });
159///
160/// let path = parse_js_path(".role").unwrap();
161/// let value = extract_from_json(&data, &path);
162/// assert_eq!(value.and_then(|v| v.as_str()), Some("admin"));
163///
164/// let path = parse_js_path(".realm_access.roles[1]").unwrap();
165/// let value = extract_from_json(&data, &path);
166/// assert_eq!(value.and_then(|v| v.as_str()), Some("moderator"));
167/// ```
168pub fn extract_from_json<'a>(
169    value: &'a serde_json::Value,
170    path: &[JsPathExp],
171) -> Option<&'a serde_json::Value> {
172    let mut current = value;
173
174    for exp in path {
175        current = match exp {
176            JsPathExp::Key(key) => current.get(key.as_str())?,
177            JsPathExp::Index(idx) => current.get(*idx)?,
178        };
179    }
180
181    Some(current)
182}
183
184/// Extract a string value from JSON using a JSPath
185pub fn extract_string_from_json(value: &serde_json::Value, path: &[JsPathExp]) -> Option<String> {
186    let extracted = extract_from_json(value, path)?;
187
188    match extracted {
189        serde_json::Value::String(s) => Some(s.clone()),
190        // Also handle numbers and booleans as strings
191        serde_json::Value::Number(n) => Some(n.to_string()),
192        serde_json::Value::Bool(b) => Some(b.to_string()),
193        _ => None,
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use serde_json::json;
201
202    #[test]
203    fn test_parse_simple_key() {
204        let path = parse_js_path(".role").unwrap();
205        assert_eq!(path, vec![JsPathExp::Key("role".into())]);
206    }
207
208    #[test]
209    fn test_parse_nested_keys() {
210        let path = parse_js_path(".realm_access.roles").unwrap();
211        assert_eq!(
212            path,
213            vec![
214                JsPathExp::Key("realm_access".into()),
215                JsPathExp::Key("roles".into()),
216            ]
217        );
218    }
219
220    #[test]
221    fn test_parse_with_index() {
222        let path = parse_js_path(".realm_access.roles[0]").unwrap();
223        assert_eq!(
224            path,
225            vec![
226                JsPathExp::Key("realm_access".into()),
227                JsPathExp::Key("roles".into()),
228                JsPathExp::Index(0),
229            ]
230        );
231    }
232
233    #[test]
234    fn test_parse_multiple_indices() {
235        let path = parse_js_path(".data[0][1]").unwrap();
236        assert_eq!(
237            path,
238            vec![
239                JsPathExp::Key("data".into()),
240                JsPathExp::Index(0),
241                JsPathExp::Index(1),
242            ]
243        );
244    }
245
246    #[test]
247    fn test_parse_without_leading_dot() {
248        let path = parse_js_path("role").unwrap();
249        assert_eq!(path, vec![JsPathExp::Key("role".into())]);
250    }
251
252    #[test]
253    fn test_parse_empty_defaults_to_role() {
254        let path = parse_js_path("").unwrap();
255        assert_eq!(path, vec![JsPathExp::Key("role".into())]);
256    }
257
258    #[test]
259    fn test_parse_invalid_index() {
260        let result = parse_js_path(".roles[abc]");
261        assert!(result.is_err());
262    }
263
264    #[test]
265    fn test_extract_simple() {
266        let data = json!({ "role": "admin" });
267        let path = parse_js_path(".role").unwrap();
268        let value = extract_from_json(&data, &path);
269        assert_eq!(value.and_then(|v| v.as_str()), Some("admin"));
270    }
271
272    #[test]
273    fn test_extract_nested() {
274        let data = json!({
275            "realm_access": {
276                "roles": ["user", "admin"]
277            }
278        });
279        let path = parse_js_path(".realm_access.roles[1]").unwrap();
280        let value = extract_from_json(&data, &path);
281        assert_eq!(value.and_then(|v| v.as_str()), Some("admin"));
282    }
283
284    #[test]
285    fn test_extract_missing() {
286        let data = json!({ "role": "admin" });
287        let path = parse_js_path(".missing.path").unwrap();
288        let value = extract_from_json(&data, &path);
289        assert!(value.is_none());
290    }
291
292    #[test]
293    fn test_extract_string_from_number() {
294        let data = json!({ "id": 123 });
295        let path = parse_js_path(".id").unwrap();
296        let value = extract_string_from_json(&data, &path);
297        assert_eq!(value, Some("123".to_string()));
298    }
299
300    #[test]
301    fn test_js_path_exp_methods() {
302        let key = JsPathExp::Key("test".into());
303        assert!(key.is_key());
304        assert!(!key.is_index());
305        assert_eq!(key.as_key(), Some("test"));
306        assert_eq!(key.as_index(), None);
307
308        let idx = JsPathExp::Index(5);
309        assert!(!idx.is_key());
310        assert!(idx.is_index());
311        assert_eq!(idx.as_key(), None);
312        assert_eq!(idx.as_index(), Some(5));
313    }
314}