1use serde::{Deserialize, Serialize};
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct ScimUser {
37 #[serde(skip_serializing_if = "Option::is_none")]
40 pub id: Option<String>,
41 #[serde(rename = "userName")]
43 pub user_name: String,
44 #[serde(default = "default_active")]
47 pub active: bool,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub name: Option<ScimName>,
50 #[serde(default, skip_serializing_if = "Vec::is_empty")]
52 pub emails: Vec<ScimEmail>,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub display_name: Option<String>,
55 #[serde(default = "default_user_schemas")]
58 pub schemas: Vec<String>,
59}
60
61fn default_active() -> bool {
62 true
63}
64
65fn default_user_schemas() -> Vec<String> {
66 vec!["urn:ietf:params:scim:schemas:core:2.0:User".into()]
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct ScimName {
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub formatted: Option<String>,
73 #[serde(default, skip_serializing_if = "Option::is_none", rename = "givenName")]
74 pub given_name: Option<String>,
75 #[serde(default, skip_serializing_if = "Option::is_none", rename = "familyName")]
76 pub family_name: Option<String>,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct ScimEmail {
81 pub value: String,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub primary: Option<bool>,
84 #[serde(default, skip_serializing_if = "Option::is_none", rename = "type")]
85 pub kind: Option<String>,
86}
87
88impl ScimUser {
89 pub fn primary_email(&self) -> &str {
92 self.emails
93 .iter()
94 .find(|e| e.primary == Some(true))
95 .map(|e| e.value.as_str())
96 .or_else(|| self.emails.first().map(|e| e.value.as_str()))
97 .unwrap_or(&self.user_name)
98 }
99
100 pub fn pretty_display_name(&self) -> String {
103 if let Some(d) = &self.display_name {
104 return d.clone();
105 }
106 if let Some(name) = &self.name {
107 if let Some(f) = &name.formatted {
108 return f.clone();
109 }
110 let parts: Vec<&str> = [&name.given_name, &name.family_name]
111 .iter()
112 .filter_map(|o| o.as_deref())
113 .collect();
114 if !parts.is_empty() {
115 return parts.join(" ");
116 }
117 }
118 self.user_name.clone()
119 }
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct ScimError {
125 pub schemas: Vec<String>,
126 pub status: String,
127 #[serde(default, skip_serializing_if = "Option::is_none", rename = "scimType")]
128 pub scim_type: Option<String>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub detail: Option<String>,
131}
132
133impl ScimError {
134 pub fn new(status: u16, detail: &str) -> Self {
135 Self {
136 schemas: vec!["urn:ietf:params:scim:api:messages:2.0:Error".into()],
137 status: status.to_string(),
138 scim_type: None,
139 detail: Some(detail.to_string()),
140 }
141 }
142}
143
144#[derive(Debug, Clone, Serialize)]
146pub struct ScimListResponse<T> {
147 pub schemas: Vec<String>,
148 #[serde(rename = "totalResults")]
149 pub total_results: usize,
150 #[serde(rename = "Resources")]
151 pub resources: Vec<T>,
152}
153
154impl<T> ScimListResponse<T> {
155 pub fn new(resources: Vec<T>) -> Self {
156 Self {
157 schemas: vec!["urn:ietf:params:scim:api:messages:2.0:ListResponse".into()],
158 total_results: resources.len(),
159 resources,
160 }
161 }
162}
163
164pub fn check_bearer(authorization_header: Option<&str>) -> bool {
168 let Some(header) = authorization_header else {
169 return false;
170 };
171 let Some(presented) = header.strip_prefix("Bearer ") else {
172 return false;
173 };
174 let Ok(expected) = std::env::var("PYLON_SCIM_TOKEN") else {
175 return false;
176 };
177 if expected.is_empty() {
178 return false;
179 }
180 crate::constant_time_eq(presented.trim().as_bytes(), expected.as_bytes())
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 fn alice() -> ScimUser {
188 ScimUser {
189 id: Some("scim-1".into()),
190 user_name: "alice@example.com".into(),
191 active: true,
192 name: Some(ScimName {
193 formatted: Some("Alice Liddell".into()),
194 given_name: Some("Alice".into()),
195 family_name: Some("Liddell".into()),
196 }),
197 emails: vec![
198 ScimEmail {
199 value: "alice@example.com".into(),
200 primary: Some(true),
201 kind: Some("work".into()),
202 },
203 ],
204 display_name: None,
205 schemas: default_user_schemas(),
206 }
207 }
208
209 #[test]
210 fn primary_email_falls_back_to_userName() {
211 let mut u = alice();
212 u.emails.clear();
213 assert_eq!(u.primary_email(), "alice@example.com");
214 }
215
216 #[test]
217 fn primary_email_picks_primary_flag() {
218 let mut u = alice();
219 u.emails = vec![
220 ScimEmail {
221 value: "alt@example.com".into(),
222 primary: Some(false),
223 kind: None,
224 },
225 ScimEmail {
226 value: "main@example.com".into(),
227 primary: Some(true),
228 kind: None,
229 },
230 ];
231 assert_eq!(u.primary_email(), "main@example.com");
232 }
233
234 #[test]
235 fn display_name_pretty_formatted() {
236 let u = alice();
237 assert_eq!(u.pretty_display_name(), "Alice Liddell");
238 }
239
240 #[test]
241 fn display_name_falls_back_to_givenName_familyName() {
242 let mut u = alice();
243 u.name.as_mut().unwrap().formatted = None;
244 assert_eq!(u.pretty_display_name(), "Alice Liddell");
245 }
246
247 #[test]
248 fn deserialize_okta_shape() {
249 let raw = r#"{
250 "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
251 "userName": "user@okta.example",
252 "active": true,
253 "name": {"givenName": "Bob", "familyName": "Smith"},
254 "emails": [{"value": "user@okta.example", "primary": true}]
255 }"#;
256 let u: ScimUser = serde_json::from_str(raw).expect("parse");
257 assert_eq!(u.user_name, "user@okta.example");
258 assert!(u.active);
259 assert_eq!(u.primary_email(), "user@okta.example");
260 assert_eq!(u.pretty_display_name(), "Bob Smith");
261 }
262
263 #[test]
264 fn list_response_serializes_with_totalResults() {
265 let list = ScimListResponse::new(vec![alice()]);
266 let json = serde_json::to_string(&list).unwrap();
267 assert!(json.contains("\"totalResults\":1"));
268 assert!(json.contains("\"Resources\""));
269 }
270
271 #[test]
272 fn check_bearer_constant_time_compare() {
273 std::env::remove_var("PYLON_SCIM_TOKEN");
275 assert!(!check_bearer(Some("Bearer something")));
276 std::env::set_var("PYLON_SCIM_TOKEN", "secret-test-token-7c4f");
277 assert!(!check_bearer(Some("Bearer wrong")));
278 assert!(!check_bearer(None));
279 assert!(!check_bearer(Some("Basic abc")));
280 assert!(check_bearer(Some("Bearer secret-test-token-7c4f")));
281 std::env::remove_var("PYLON_SCIM_TOKEN");
282 }
283}