Skip to main content

pylon_auth/
scim.rs

1//! SCIM 2.0 — System for Cross-domain Identity Management.
2//!
3//! Lets enterprise IdPs (Okta, Azure AD, Workday, Rippling) auto-
4//! provision users into pylon-managed apps. The IdP POSTs to
5//! `/scim/v2/Users` to create a user, GETs `/scim/v2/Users/<id>`
6//! to read, PATCHes to update, DELETEs to deactivate. Same shape
7//! for `/scim/v2/Groups`.
8//!
9//! **Status: library only — HTTP endpoints not yet wired.**
10//! ScimUser / ScimError / check_bearer ship today as primitives so
11//! apps that want to roll their own SCIM endpoints can compose
12//! them. The pylon-shipped `/scim/v2/*` routes (POST/GET/PATCH/
13//! DELETE Users + Groups) are queued for the next wave.
14//!
15//! Auth: SCIM endpoints accept a static bearer token configured via
16//! `PYLON_SCIM_TOKEN`. IdPs configure this once when they connect.
17//!
18//! Spec: <https://datatracker.ietf.org/doc/html/rfc7644>
19//!
20//! Pylon's SCIM mapping:
21//!   - SCIM `userName` → User row's `email`
22//!   - SCIM `name.formatted` → User row's `displayName`
23//!   - SCIM `active=false` → soft-delete (set `deletedAt` on User row;
24//!     app decides whether to hard-delete)
25//!
26//! The endpoint wiring lives in `routes/auth.rs`. This module just
27//! provides the request/response type definitions and the
28//! field-level mapping helpers.
29
30use serde::{Deserialize, Serialize};
31
32/// SCIM User schema (subset). Most IdPs send a much fuller object
33/// — pylon ignores anything we don't model. `extra` captures it
34/// for round-trip on PATCH.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct ScimUser {
37    /// SCIM "id" — the IdP-assigned identifier. Pylon uses its own
38    /// User row id internally and stores SCIM id as `scimId`.
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub id: Option<String>,
41    /// Universal SCIM identifier — typically the email.
42    #[serde(rename = "userName")]
43    pub user_name: String,
44    /// Whether the IdP considers this user active. `false` is the
45    /// soft-delete signal.
46    #[serde(default = "default_active")]
47    pub active: bool,
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub name: Option<ScimName>,
50    /// First email is treated as primary if `primary` flag missing.
51    #[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    /// SCIM schemas array — must include at least
56    /// `urn:ietf:params:scim:schemas:core:2.0:User`.
57    #[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    /// Pull the primary email — `primary=true` first, else the first
90    /// element, else fall back to `userName`.
91    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    /// Best-effort display name — `displayName` first, else
101    /// `name.formatted`, else `<given> <family>`.
102    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/// SCIM error response shape — RFC 7644 §3.12.
123#[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/// SCIM list response (RFC 7644 §3.4.2).
145#[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
164/// Validate a bearer token against `PYLON_SCIM_TOKEN`. Returns
165/// `true` only if the env var is set + the bearer matches via
166/// constant-time compare.
167pub 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        // Without the env var set, all checks fail.
274        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}