Skip to main content

postrust_auth/
claims.rs

1//! JWT claims handling.
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6/// Parsed JWT claims for use in requests.
7#[derive(Clone, Debug, Default, Serialize, Deserialize)]
8pub struct Claims {
9    /// All claims as key-value pairs
10    pub values: HashMap<String, serde_json::Value>,
11}
12
13impl Claims {
14    /// Create empty claims.
15    pub fn new() -> Self {
16        Self::default()
17    }
18
19    /// Get a claim value as a string.
20    pub fn get_str(&self, key: &str) -> Option<&str> {
21        self.values.get(key).and_then(|v| v.as_str())
22    }
23
24    /// Get a claim value as an integer.
25    pub fn get_i64(&self, key: &str) -> Option<i64> {
26        self.values.get(key).and_then(|v| v.as_i64())
27    }
28
29    /// Get a claim value as a boolean.
30    pub fn get_bool(&self, key: &str) -> Option<bool> {
31        self.values.get(key).and_then(|v| v.as_bool())
32    }
33
34    /// Get a claim value.
35    pub fn get(&self, key: &str) -> Option<&serde_json::Value> {
36        self.values.get(key)
37    }
38
39    /// Set a claim value.
40    pub fn set(&mut self, key: impl Into<String>, value: serde_json::Value) {
41        self.values.insert(key.into(), value);
42    }
43
44    /// Convert to JSON string for GUC.
45    pub fn to_json(&self) -> String {
46        serde_json::to_string(&self.values).unwrap_or_else(|_| "{}".to_string())
47    }
48
49    /// Get claims whose names can safely extend a PostgreSQL setting prefix.
50    ///
51    /// Claims whose dot-separated names are not PostgreSQL identifiers remain
52    /// available in the complete JSON document but cannot become settings.
53    pub fn prefixed_entries(&self, prefix: &str) -> Vec<(String, String)> {
54        self.values
55            .iter()
56            .filter(|(key, _)| valid_setting_suffix(key))
57            .map(|(k, v)| {
58                let key = format!("{}{}", prefix, k);
59                let value = match v {
60                    serde_json::Value::String(s) => s.clone(),
61                    other => other.to_string(),
62                };
63                (key, value)
64            })
65            .collect()
66    }
67}
68
69fn valid_setting_suffix(value: &str) -> bool {
70    !value.is_empty()
71        && value.split('.').all(|segment| {
72            let mut characters = segment.chars();
73            characters
74                .next()
75                .is_some_and(|first| first.is_ascii_alphabetic() || first == '_')
76                && characters.all(|character| character.is_ascii_alphanumeric() || character == '_')
77        })
78}
79
80impl From<HashMap<String, serde_json::Value>> for Claims {
81    fn from(values: HashMap<String, serde_json::Value>) -> Self {
82        Self { values }
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn test_claims_get_str() {
92        let mut claims = Claims::new();
93        claims.set("role", serde_json::Value::String("admin".into()));
94
95        assert_eq!(claims.get_str("role"), Some("admin"));
96        assert_eq!(claims.get_str("missing"), None);
97    }
98
99    #[test]
100    fn test_claims_get_i64() {
101        let mut claims = Claims::new();
102        claims.set("user_id", serde_json::Value::Number(42.into()));
103
104        assert_eq!(claims.get_i64("user_id"), Some(42));
105    }
106
107    #[test]
108    fn test_claims_to_json() {
109        let mut claims = Claims::new();
110        claims.set("role", serde_json::Value::String("user".into()));
111        claims.set("id", serde_json::Value::Number(123.into()));
112
113        let json = claims.to_json();
114        assert!(json.contains("role"));
115        assert!(json.contains("user"));
116    }
117
118    #[test]
119    fn test_claims_prefixed_entries() {
120        let mut claims = Claims::new();
121        claims.set("role", serde_json::Value::String("admin".into()));
122        claims.set(
123            "email",
124            serde_json::Value::String("test@example.com".into()),
125        );
126
127        let entries = claims.prefixed_entries("request.jwt.claims.");
128        assert_eq!(entries.len(), 2);
129        assert!(entries.iter().any(|(k, _)| k == "request.jwt.claims.role"));
130    }
131
132    #[test]
133    fn prefixed_entries_render_structured_values_as_json() {
134        let mut claims = Claims::new();
135        claims.set("org_class", serde_json::json!(["defense", "datacenters"]));
136
137        let entries = claims.prefixed_entries("request.jwt.claims.");
138        assert_eq!(
139            entries,
140            vec![(
141                "request.jwt.claims.org_class".to_string(),
142                r#"["defense","datacenters"]"#.to_string()
143            )]
144        );
145    }
146
147    #[test]
148    fn prefixed_entries_skip_claim_names_that_are_not_postgresql_settings() {
149        let mut claims = Claims::new();
150        claims.set(
151            "https://hasura.io/jwt/claims",
152            serde_json::json!({"x-hasura-role": "user"}),
153        );
154        claims.set("2fa", serde_json::json!(true));
155        claims.set("user_id", serde_json::json!("user-1"));
156
157        let entries = claims.prefixed_entries("request.jwt.claims.");
158        assert_eq!(
159            entries,
160            vec![(
161                "request.jwt.claims.user_id".to_string(),
162                "user-1".to_string()
163            )]
164        );
165    }
166}