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