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(
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    /// Pull the primary email — `primary=true` first, else the first
94    /// element, else fall back to `userName`.
95    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    /// Best-effort display name — `displayName` first, else
105    /// `name.formatted`, else `<given> <family>`.
106    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/// SCIM error response shape — RFC 7644 §3.12.
127#[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/// SCIM list response (RFC 7644 §3.4.2).
149#[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
168/// Validate a bearer token against `PYLON_SCIM_TOKEN`. Returns
169/// `true` only if the env var is set + the bearer matches via
170/// constant-time compare.
171pub 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        // Without the env var set, all checks fail.
276        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}