use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScimUser {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(rename = "userName")]
pub user_name: String,
#[serde(default = "default_active")]
pub active: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<ScimName>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub emails: Vec<ScimEmail>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(default = "default_user_schemas")]
pub schemas: Vec<String>,
}
fn default_active() -> bool {
true
}
fn default_user_schemas() -> Vec<String> {
vec!["urn:ietf:params:scim:schemas:core:2.0:User".into()]
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScimName {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub formatted: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none", rename = "givenName")]
pub given_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none", rename = "familyName")]
pub family_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScimEmail {
pub value: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub primary: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none", rename = "type")]
pub kind: Option<String>,
}
impl ScimUser {
pub fn primary_email(&self) -> &str {
self.emails
.iter()
.find(|e| e.primary == Some(true))
.map(|e| e.value.as_str())
.or_else(|| self.emails.first().map(|e| e.value.as_str()))
.unwrap_or(&self.user_name)
}
pub fn pretty_display_name(&self) -> String {
if let Some(d) = &self.display_name {
return d.clone();
}
if let Some(name) = &self.name {
if let Some(f) = &name.formatted {
return f.clone();
}
let parts: Vec<&str> = [&name.given_name, &name.family_name]
.iter()
.filter_map(|o| o.as_deref())
.collect();
if !parts.is_empty() {
return parts.join(" ");
}
}
self.user_name.clone()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScimError {
pub schemas: Vec<String>,
pub status: String,
#[serde(default, skip_serializing_if = "Option::is_none", rename = "scimType")]
pub scim_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
impl ScimError {
pub fn new(status: u16, detail: &str) -> Self {
Self {
schemas: vec!["urn:ietf:params:scim:api:messages:2.0:Error".into()],
status: status.to_string(),
scim_type: None,
detail: Some(detail.to_string()),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ScimListResponse<T> {
pub schemas: Vec<String>,
#[serde(rename = "totalResults")]
pub total_results: usize,
#[serde(rename = "Resources")]
pub resources: Vec<T>,
}
impl<T> ScimListResponse<T> {
pub fn new(resources: Vec<T>) -> Self {
Self {
schemas: vec!["urn:ietf:params:scim:api:messages:2.0:ListResponse".into()],
total_results: resources.len(),
resources,
}
}
}
pub fn check_bearer(authorization_header: Option<&str>) -> bool {
let Some(header) = authorization_header else {
return false;
};
let Some(presented) = header.strip_prefix("Bearer ") else {
return false;
};
let Ok(expected) = std::env::var("PYLON_SCIM_TOKEN") else {
return false;
};
if expected.is_empty() {
return false;
}
crate::constant_time_eq(presented.trim().as_bytes(), expected.as_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
fn alice() -> ScimUser {
ScimUser {
id: Some("scim-1".into()),
user_name: "alice@example.com".into(),
active: true,
name: Some(ScimName {
formatted: Some("Alice Liddell".into()),
given_name: Some("Alice".into()),
family_name: Some("Liddell".into()),
}),
emails: vec![
ScimEmail {
value: "alice@example.com".into(),
primary: Some(true),
kind: Some("work".into()),
},
],
display_name: None,
schemas: default_user_schemas(),
}
}
#[test]
fn primary_email_falls_back_to_userName() {
let mut u = alice();
u.emails.clear();
assert_eq!(u.primary_email(), "alice@example.com");
}
#[test]
fn primary_email_picks_primary_flag() {
let mut u = alice();
u.emails = vec![
ScimEmail {
value: "alt@example.com".into(),
primary: Some(false),
kind: None,
},
ScimEmail {
value: "main@example.com".into(),
primary: Some(true),
kind: None,
},
];
assert_eq!(u.primary_email(), "main@example.com");
}
#[test]
fn display_name_pretty_formatted() {
let u = alice();
assert_eq!(u.pretty_display_name(), "Alice Liddell");
}
#[test]
fn display_name_falls_back_to_givenName_familyName() {
let mut u = alice();
u.name.as_mut().unwrap().formatted = None;
assert_eq!(u.pretty_display_name(), "Alice Liddell");
}
#[test]
fn deserialize_okta_shape() {
let raw = r#"{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"userName": "user@okta.example",
"active": true,
"name": {"givenName": "Bob", "familyName": "Smith"},
"emails": [{"value": "user@okta.example", "primary": true}]
}"#;
let u: ScimUser = serde_json::from_str(raw).expect("parse");
assert_eq!(u.user_name, "user@okta.example");
assert!(u.active);
assert_eq!(u.primary_email(), "user@okta.example");
assert_eq!(u.pretty_display_name(), "Bob Smith");
}
#[test]
fn list_response_serializes_with_totalResults() {
let list = ScimListResponse::new(vec![alice()]);
let json = serde_json::to_string(&list).unwrap();
assert!(json.contains("\"totalResults\":1"));
assert!(json.contains("\"Resources\""));
}
#[test]
fn check_bearer_constant_time_compare() {
std::env::remove_var("PYLON_SCIM_TOKEN");
assert!(!check_bearer(Some("Bearer something")));
std::env::set_var("PYLON_SCIM_TOKEN", "secret-test-token-7c4f");
assert!(!check_bearer(Some("Bearer wrong")));
assert!(!check_bearer(None));
assert!(!check_bearer(Some("Basic abc")));
assert!(check_bearer(Some("Bearer secret-test-token-7c4f")));
std::env::remove_var("PYLON_SCIM_TOKEN");
}
}