Skip to main content

camel_auth/
claims.rs

1use camel_api::security_policy::Principal;
2use serde::{Deserialize, Serialize};
3
4use crate::types::AuthError;
5
6pub trait ClaimsMapper: Send + Sync {
7    fn to_principal(&self, claims: &serde_json::Value) -> Result<Principal, AuthError>;
8}
9
10#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
11pub struct ClaimPaths {
12    pub subject: String,
13    pub roles: Vec<String>,
14    pub scopes: Option<String>,
15}
16
17pub struct JsonPointerClaimsMapper {
18    subject_path: String,
19    role_paths: Vec<String>,
20    scope_path: Option<String>,
21}
22
23/// Escape a string for use as a JSON Pointer token segment (RFC 6901):
24/// `~` becomes `~0` and `/` becomes `~1`.
25pub fn escape_json_pointer(s: &str) -> String {
26    let mut out = String::with_capacity(s.len());
27    for c in s.chars() {
28        match c {
29            '~' => out.push_str("~0"),
30            '/' => out.push_str("~1"),
31            _ => out.push(c),
32        }
33    }
34    out
35}
36
37impl JsonPointerClaimsMapper {
38    pub fn new(paths: ClaimPaths) -> Self {
39        Self {
40            subject_path: paths.subject,
41            role_paths: paths.roles,
42            scope_path: paths.scopes,
43        }
44    }
45}
46
47impl ClaimsMapper for JsonPointerClaimsMapper {
48    fn to_principal(&self, claims: &serde_json::Value) -> Result<Principal, AuthError> {
49        let subject = claims
50            .pointer(&self.subject_path)
51            .and_then(|v| v.as_str())
52            .filter(|s| !s.is_empty())
53            .ok_or_else(|| {
54                AuthError::TokenInvalid(format!(
55                    "missing or empty subject at JSON pointer {}",
56                    self.subject_path
57                ))
58            })?
59            .to_string();
60
61        let mut roles: Vec<String> = Vec::new();
62        for path in &self.role_paths {
63            if let Some(arr) = claims.pointer(path).and_then(|v| v.as_array()) {
64                roles.extend(arr.iter().filter_map(|v| v.as_str()).map(String::from));
65            }
66        }
67        roles.sort();
68        roles.dedup();
69
70        let scopes = self
71            .scope_path
72            .as_ref()
73            .and_then(|p| claims.pointer(p))
74            .map(|v| match v {
75                serde_json::Value::String(s) => s.split_whitespace().map(String::from).collect(),
76                serde_json::Value::Array(arr) => arr
77                    .iter()
78                    .filter_map(|v| v.as_str().map(String::from))
79                    .collect(),
80                _ => Vec::new(),
81            })
82            .unwrap_or_default();
83
84        Ok(Principal {
85            subject,
86            issuer: claims
87                .pointer("/iss")
88                .and_then(|v| v.as_str())
89                .unwrap_or("")
90                .to_string(),
91            audience: claims
92                .pointer("/aud")
93                .and_then(|v| match v {
94                    serde_json::Value::String(s) => Some(vec![s.clone()]),
95                    serde_json::Value::Array(arr) => Some(
96                        arr.iter()
97                            .filter_map(|v| v.as_str())
98                            .map(String::from)
99                            .collect(),
100                    ),
101                    _ => None,
102                })
103                .unwrap_or_default(),
104            roles,
105            scopes,
106            claims: claims.clone(),
107        })
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use serde_json::json;
115
116    fn mapper(paths: ClaimPaths) -> JsonPointerClaimsMapper {
117        JsonPointerClaimsMapper::new(paths)
118    }
119
120    fn default_paths() -> ClaimPaths {
121        ClaimPaths {
122            subject: "/sub".into(),
123            roles: vec!["/groups".into()],
124            scopes: None,
125        }
126    }
127
128    #[test]
129    fn extracts_subject() {
130        let claims = json!({"sub": "user-1"});
131        let principal = mapper(default_paths()).to_principal(&claims).unwrap();
132        assert_eq!(principal.subject, "user-1");
133    }
134
135    #[test]
136    fn missing_subject_returns_error() {
137        let claims = json!({"no_sub": "x"});
138        let result = mapper(default_paths()).to_principal(&claims);
139        assert!(result.is_err());
140    }
141
142    #[test]
143    fn empty_subject_returns_error() {
144        let claims = json!({"sub": ""});
145        let result = mapper(default_paths()).to_principal(&claims);
146        assert!(result.is_err());
147    }
148
149    #[test]
150    fn extracts_roles_from_single_path() {
151        let claims = json!({
152            "sub": "u",
153            "groups": ["admin", "user"]
154        });
155        let principal = mapper(default_paths()).to_principal(&claims).unwrap();
156        assert!(principal.has_role("admin"));
157        assert!(principal.has_role("user"));
158    }
159
160    #[test]
161    fn extracts_roles_from_multiple_paths_and_deduplicates() {
162        let paths = ClaimPaths {
163            subject: "/sub".into(),
164            roles: vec!["/groups".into(), "/app_roles".into()],
165            scopes: None,
166        };
167        let claims = json!({
168            "sub": "u",
169            "groups": ["admin"],
170            "app_roles": ["admin", "editor"]
171        });
172        let principal = mapper(paths).to_principal(&claims).unwrap();
173        assert_eq!(principal.roles, vec!["admin", "editor"]);
174    }
175
176    #[test]
177    fn no_role_paths_produces_empty_roles() {
178        let paths = ClaimPaths {
179            subject: "/sub".into(),
180            roles: vec![],
181            scopes: None,
182        };
183        let claims = json!({"sub": "u"});
184        let principal = mapper(paths).to_principal(&claims).unwrap();
185        assert!(principal.roles.is_empty());
186    }
187
188    #[test]
189    fn extracts_scopes_from_space_separated_string() {
190        let paths = ClaimPaths {
191            subject: "/sub".into(),
192            roles: vec![],
193            scopes: Some("/scope".into()),
194        };
195        let claims = json!({"sub": "u", "scope": "read write"});
196        let principal = mapper(paths).to_principal(&claims).unwrap();
197        assert_eq!(principal.scopes, vec!["read", "write"]);
198    }
199
200    #[test]
201    fn extracts_scopes_from_array() {
202        let paths = ClaimPaths {
203            subject: "/sub".into(),
204            roles: vec![],
205            scopes: Some("/scope".into()),
206        };
207        let claims = json!({"sub": "u", "scope": ["read", "write", "admin"]});
208        let principal = mapper(paths).to_principal(&claims).unwrap();
209        assert_eq!(principal.scopes, vec!["read", "write", "admin"]);
210    }
211
212    #[test]
213    fn claims_stored_in_principal() {
214        let claims = json!({"sub": "u", "custom": "value"});
215        let principal = mapper(default_paths()).to_principal(&claims).unwrap();
216        assert_eq!(principal.claims["custom"], "value");
217    }
218
219    #[test]
220    fn custom_subject_path() {
221        let paths = ClaimPaths {
222            subject: "/preferred_username".into(),
223            roles: vec![],
224            scopes: None,
225        };
226        let claims = json!({"preferred_username": "alice"});
227        let principal = mapper(paths).to_principal(&claims).unwrap();
228        assert_eq!(principal.subject, "alice");
229    }
230}