Skip to main content

alibi_plugins/
scim.rs

1//! SCIM server discovery operations.
2use alibi_core::endpoint::{EndpointCall, EndpointDefinition, EndpointResponse, ServerEndpoint};
3use alibi_core::{
4    AuthContext, AuthError, AuthPlugin, AuthRequest, AuthResponse, AuthResult, AuthRoute,
5    AuthSchema, HttpMethod,
6};
7use async_trait::async_trait;
8use serde_json::{Value, json};
9mod authentication;
10mod collection;
11mod compatibility;
12mod managed;
13mod projection;
14mod resources;
15mod user_attributes;
16mod user_input;
17mod user_patch;
18mod user_profile;
19pub use authentication::{ScimBearerTokenVerificationInput, ScimBearerTokenVerifier};
20pub use managed::{ScimManagedConnectionInput, ScimManagedCredentialRotationInput};
21pub use projection::{
22    ConfiguredScimPlugin, ScimAuthorizationSource, ScimIdentity, ScimIdentityResolution,
23    ScimIdentityResolutionInput, ScimIdentitySource, ScimIdentityState, ScimProjectedUserState,
24    ScimProjection, ScimRoleExistenceInput, ScimRoleMappingInput, ScimRoleProjection,
25};
26
27/// A static inbound provisioning credential.
28#[derive(Clone)]
29pub struct ScimCredential {
30    pub id: String,
31    pub token: String,
32    pub scopes: Option<Vec<String>>,
33    pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
34}
35impl std::fmt::Debug for ScimCredential {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        f.debug_struct("ScimCredential")
38            .field("id", &self.id)
39            .field("scopes", &self.scopes)
40            .field("expires_at", &self.expires_at)
41            .finish_non_exhaustive()
42    }
43}
44impl ScimCredential {
45    #[must_use]
46    pub fn new(id: impl Into<String>, token: impl Into<String>) -> Self {
47        Self {
48            id: id.into(),
49            token: token.into(),
50            scopes: None,
51            expires_at: None,
52        }
53    }
54}
55/// A connection owns an isolated set of canonical SCIM resources.
56#[derive(Clone, Debug)]
57pub struct ScimConnectionOptions {
58    pub id: String,
59    pub provisioning_domain_id: Option<String>,
60    pub credentials: Vec<ScimCredential>,
61}
62/// Policy for the framework-managed provisioning credential catalog.
63#[derive(Clone)]
64pub struct ScimManagedConnectionOptions {
65    pub credential_hash_secret: String,
66    pub max_active_credentials: usize,
67    pub last_used_write_interval_seconds: u64,
68}
69impl std::fmt::Debug for ScimManagedConnectionOptions {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        f.debug_struct("ScimManagedConnectionOptions")
72            .field("max_active_credentials", &self.max_active_credentials)
73            .field(
74                "last_used_write_interval_seconds",
75                &self.last_used_write_interval_seconds,
76            )
77            .finish_non_exhaustive()
78    }
79}
80impl ScimManagedConnectionOptions {
81    #[must_use]
82    pub fn new(secret: impl Into<String>) -> Self {
83        Self {
84            credential_hash_secret: secret.into(),
85            max_active_credentials: 5,
86            last_used_write_interval_seconds: 300,
87        }
88    }
89}
90/// Optional input compatibility with external provisioning clients.
91#[derive(Clone, Debug, Default)]
92pub struct ScimCompatibilityOptions {
93    pub microsoft_entra: Option<ScimMicrosoftEntraCompatibilityOptions>,
94}
95/// Classic Microsoft Entra Group input compatibility; output remains canonical.
96#[derive(Clone, Debug, Default)]
97pub struct ScimMicrosoftEntraCompatibilityOptions {
98    pub accept_legacy_group_schema: bool,
99}
100
101#[derive(Clone, Debug, Default)]
102pub struct ScimPlugin {
103    managed_connections: Option<ScimManagedConnectionOptions>,
104    connections: Vec<ScimConnectionOptions>,
105    has_token_verifier: bool,
106    accept_legacy_group_schema: bool,
107}
108impl ScimPlugin {
109    #[must_use]
110    pub const fn new() -> Self {
111        Self {
112            managed_connections: None,
113            connections: Vec::new(),
114            has_token_verifier: false,
115            accept_legacy_group_schema: false,
116        }
117    }
118    /// Configure provisioning-client input compatibility.
119    #[must_use]
120    pub fn compatibility(mut self, options: ScimCompatibilityOptions) -> Self {
121        self.accept_legacy_group_schema = options
122            .microsoft_entra
123            .is_some_and(|options| options.accept_legacy_group_schema);
124        self
125    }
126    #[must_use]
127    pub fn managed_connections(mut self, options: ScimManagedConnectionOptions) -> Self {
128        self.managed_connections = Some(options);
129        self
130    }
131    #[must_use]
132    pub fn connections(mut self, connections: Vec<ScimConnectionOptions>) -> Self {
133        self.connections = connections;
134        self
135    }
136    /// Delete a canonical User and reconcile its durable identity and access state.
137    /// # Errors
138    /// Returns an error if parameters cannot be serialized.
139    pub fn delete_user_endpoint(id: &str) -> AuthResult<ServerEndpoint<()>> {
140        ServerEndpoint::new("scim", "deleteSCIMUser").with_params(&json!({"userId":id}))
141    }
142    /// Apply SCIM PATCH operations to a connection-owned canonical Group.
143    /// # Errors
144    /// Returns an error if parameters cannot be serialized.
145    pub fn patch_group_endpoint(id: &str) -> AuthResult<ServerEndpoint<Value>> {
146        ServerEndpoint::new("scim", "patchSCIMGroup").with_params(&json!({"groupId":id}))
147    }
148    /// Replace all mutable attributes of a connection-owned canonical Group.
149    /// # Errors
150    /// Returns an error if parameters cannot be serialized.
151    pub fn replace_group_endpoint(id: &str) -> AuthResult<ServerEndpoint<Value>> {
152        ServerEndpoint::new("scim", "replaceSCIMGroup").with_params(&json!({"groupId":id}))
153    }
154    /// Delete a connection-owned canonical SCIM Group.
155    /// # Errors
156    /// Returns an error if parameters cannot be serialized.
157    pub fn delete_group_endpoint(id: &str) -> AuthResult<ServerEndpoint<()>> {
158        ServerEndpoint::new("scim", "deleteSCIMGroup").with_params(&json!({"groupId":id}))
159    }
160    /// Apply ordered changes to a connection-owned canonical User.
161    /// # Errors
162    /// Returns an error if parameters cannot be serialized.
163    pub fn patch_user_endpoint(id: &str) -> AuthResult<ServerEndpoint<Value>> {
164        ServerEndpoint::new("scim", "patchSCIMUser").with_params(&json!({"userId":id}))
165    }
166    /// Replace the mutable attributes of a connection-owned canonical User.
167    /// # Errors
168    /// Returns an error if parameters cannot be serialized.
169    pub fn replace_user_endpoint(id: &str) -> AuthResult<ServerEndpoint<Value>> {
170        ServerEndpoint::new("scim", "replaceSCIMUser").with_params(&json!({"userId":id}))
171    }
172    /// Provision a connection-owned canonical SCIM User.
173    #[must_use]
174    pub const fn create_user_endpoint() -> ServerEndpoint<Value> {
175        ServerEndpoint::new("scim", "createSCIMUser")
176    }
177    /// Create a connection-owned canonical SCIM Group.
178    #[must_use]
179    pub const fn create_group_endpoint() -> ServerEndpoint<Value> {
180        ServerEndpoint::new("scim", "createSCIMGroup")
181    }
182    /// List the connection-owned SCIM Users through the server API.
183    #[must_use]
184    pub const fn users_endpoint() -> ServerEndpoint<Value> {
185        ServerEndpoint::new("scim", "listSCIMUsers")
186    }
187    /// List the connection-owned SCIM Groups through the server API.
188    #[must_use]
189    pub const fn groups_endpoint() -> ServerEndpoint<Value> {
190        ServerEndpoint::new("scim", "listSCIMGroups")
191    }
192    /// Read a connection-owned SCIM User through the server API.
193    /// # Errors
194    /// Returns an error if parameters cannot be serialized.
195    pub fn user_endpoint(id: &str) -> AuthResult<ServerEndpoint<Value>> {
196        ServerEndpoint::new("scim", "getSCIMUser").with_params(&json!({"userId":id}))
197    }
198    /// Read a connection-owned SCIM Group through the server API.
199    /// # Errors
200    /// Returns an error if parameters cannot be serialized.
201    pub fn group_endpoint(id: &str) -> AuthResult<ServerEndpoint<Value>> {
202        ServerEndpoint::new("scim", "getSCIMGroup").with_params(&json!({"groupId":id}))
203    }
204    /// Discover the installed SCIM protocol capabilities without credentials.
205    #[must_use]
206    pub const fn service_provider_config_endpoint() -> ServerEndpoint<Value> {
207        ServerEndpoint::new("scim", "getSCIMServiceProviderConfig")
208    }
209    /// List the SCIM resource types and their optional extensions.
210    #[must_use]
211    pub const fn resource_types_endpoint() -> ServerEndpoint<Value> {
212        ServerEndpoint::new("scim", "getSCIMResourceTypes")
213    }
214    /// List the SCIM User, Enterprise User and Group schema contracts.
215    #[must_use]
216    pub const fn schemas_endpoint() -> ServerEndpoint<Value> {
217        ServerEndpoint::new("scim", "getSCIMSchemas")
218    }
219    fn schemas<S: AuthSchema>(ctx: &AuthContext<S>) -> AuthResult<Value> {
220        // Runtime protocol descriptors match @better-auth/scim 1.7.7; keep them
221        // independent of test fixtures and resolve locations for this application.
222        let mut schemas: Vec<Value> = serde_json::from_str(include_str!("scim/schemas.json"))?;
223        let base = format!(
224            "{}{}",
225            ctx.config.base_url.trim_end_matches('/'),
226            ctx.config.base_path.trim_end_matches('/')
227        );
228        for schema in &mut schemas {
229            if let Some(meta) = schema.get_mut("meta").and_then(Value::as_object_mut)
230                && let Some(path) = meta.get("location").and_then(Value::as_str)
231            {
232                let location = format!("{base}{path}");
233                _ = meta.insert("location".into(), Value::String(location));
234            }
235        }
236        Ok(
237            json!({"schemas":["urn:ietf:params:scim:api:messages:2.0:ListResponse"],"totalResults":schemas.len(),"startIndex":1,"itemsPerPage":schemas.len(),"Resources":schemas}),
238        )
239    }
240    /// Read a SCIM schema by its raw or URI-encoded identifier.
241    /// # Errors
242    /// Returns an error if path parameters cannot be serialized.
243    pub fn schema_endpoint(id: &str) -> AuthResult<ServerEndpoint<Value>> {
244        ServerEndpoint::new("scim", "getSCIMSchema").with_params(&json!({"schemaId":id}))
245    }
246    fn schema<S: AuthSchema>(ctx: &AuthContext<S>, id: &str) -> AuthResult<EndpointResponse> {
247        let decoded = urlencoding::decode(id).unwrap_or(std::borrow::Cow::Borrowed(id));
248        let schemas = Self::schemas(ctx)?;
249        let schema = schemas
250            .get("Resources")
251            .and_then(Value::as_array)
252            .and_then(|schemas| {
253                schemas.iter().find(|schema| {
254                    schema.get("id").and_then(Value::as_str) == Some(decoded.as_ref())
255                })
256            });
257        Self::discovery_result(schema, "Schema not found")
258    }
259    fn discovery_result(resource: Option<&Value>, detail: &str) -> AuthResult<EndpointResponse> {
260        let response = if let Some(resource) = resource {
261            EndpointResponse::json(resource)?
262        } else {
263            EndpointResponse::error(AuthError::Api {status:404,code:None,message:detail.into()})
264                .with_error_body(alibi_core::utils::json::parse_value(&json!({"schemas":["urn:ietf:params:scim:api:messages:2.0:Error"],"status":"404","detail":detail}).to_string())?)
265        };
266        Ok(response.with_header("x-better-auth-scim-response", "1"))
267    }
268    /// Read one case-sensitive SCIM resource type through the server API.
269    /// # Errors
270    /// Returns an error if the path parameters cannot be serialized.
271    pub fn resource_type_endpoint(id: &str) -> AuthResult<ServerEndpoint<Value>> {
272        ServerEndpoint::new("scim", "getSCIMResourceType")
273            .with_params(&json!({"resourceTypeId":id}))
274    }
275    fn resource_type<S: AuthSchema>(
276        ctx: &AuthContext<S>,
277        id: Option<&str>,
278    ) -> AuthResult<EndpointResponse> {
279        let types = Self::resource_types(ctx);
280        let resource = types
281            .get("Resources")
282            .and_then(Value::as_array)
283            .and_then(|types| {
284                types
285                    .iter()
286                    .find(|resource| resource.get("id").and_then(Value::as_str) == id)
287            });
288        Self::discovery_result(resource, "Resource type not found")
289    }
290    fn resource_types<S: AuthSchema>(ctx: &AuthContext<S>) -> Value {
291        let base = format!(
292            "{}{}",
293            ctx.config.base_url.trim_end_matches('/'),
294            ctx.config.base_path.trim_end_matches('/')
295        );
296        json!({"schemas":["urn:ietf:params:scim:api:messages:2.0:ListResponse"],"totalResults":2,"startIndex":1,"itemsPerPage":2,"Resources":[
297            {"schemas":["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],"id":"User","name":"User","endpoint":"/Users","description":"User Account","schema":"urn:ietf:params:scim:schemas:core:2.0:User","schemaExtensions":[{"schema":"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User","required":false}],"meta":{"resourceType":"ResourceType","location":format!("{base}/scim/v2/ResourceTypes/User")}},
298            {"schemas":["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],"id":"Group","name":"Group","endpoint":"/Groups","description":"Group","schema":"urn:ietf:params:scim:schemas:core:2.0:Group","meta":{"resourceType":"ResourceType","location":format!("{base}/scim/v2/ResourceTypes/Group")}}
299        ]})
300    }
301    fn service_provider_config<S: AuthSchema>(ctx: &AuthContext<S>) -> Value {
302        json!({
303            "schemas":["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
304            "patch":{"supported":true}, "bulk":{"supported":false,"maxOperations":0,"maxPayloadSize":0},
305            "filter":{"supported":true,"maxResults":100}, "changePassword":{"supported":false},
306            "sort":{"supported":false},"etag":{"supported":false},
307            "authenticationSchemes":[{"name":"OAuth Bearer Token","description":"Authentication using a bearer token in the Authorization header.","specUri":"https://www.rfc-editor.org/info/rfc6750","type":"oauthbearertoken","primary":true}],
308            "meta":{"resourceType":"ServiceProviderConfig","location":format!("{}{}{}",ctx.config.base_url.trim_end_matches('/'),ctx.config.base_path.trim_end_matches('/'),"/scim/v2/ServiceProviderConfig")}
309        })
310    }
311}
312#[async_trait]
313impl<S: AuthSchema> AuthPlugin<S> for ScimPlugin {
314    async fn on_http_request_action(
315        &self,
316        req: &AuthRequest,
317        _ctx: &AuthContext<S>,
318    ) -> AuthResult<Option<alibi_core::HttpRequestAction>> {
319        let path = req.url().map_or_else(|| req.path(), url::Url::path);
320        if !path.contains("/scim/v2/") {
321            return Ok(None);
322        }
323        if req.method() == &HttpMethod::Delete {
324            let mut replacement = req.clone();
325            replacement.body = None;
326            return Ok(Some(alibi_core::HttpRequestAction::ReplaceRequest(
327                Box::new(replacement),
328            )));
329        }
330        if !matches!(
331            req.method(),
332            HttpMethod::Post | HttpMethod::Put | HttpMethod::Patch
333        ) {
334            return Ok(None);
335        }
336        let media = req
337            .headers
338            .iter()
339            .find_map(|(name, value)| name.eq_ignore_ascii_case("content-type").then_some(value))
340            .and_then(|value| value.split(';').next())
341            .map(alibi_core::utils::javascript::trim);
342        if !media.is_some_and(|media| {
343            media.eq_ignore_ascii_case("application/json")
344                || media.eq_ignore_ascii_case("application/scim+json")
345        }) {
346            return Ok(Some(alibi_core::HttpRequestAction::Respond(
347                resources::to_http_response(&resources::error(
348                    415,
349                    "SCIM requests must use application/scim+json or application/json",
350                    None,
351                ))?,
352            )));
353        }
354        let Ok(mut body) = alibi_core::utils::json::from_slice::<alibi_core::utils::json::JsValue>(
355            req.body.as_deref().unwrap_or_default(),
356        ) else {
357            return Ok(Some(alibi_core::HttpRequestAction::Respond(
358                resources::to_http_response(&resources::error(
359                    400,
360                    "SCIM request body must contain valid JSON",
361                    Some("invalidSyntax"),
362                ))?,
363            )));
364        };
365        match compatibility::normalize_group(
366            path,
367            req.method(),
368            &mut body,
369            self.accept_legacy_group_schema,
370        ) {
371            Ok(false) => Ok(None),
372            Ok(true) => {
373                let mut replacement = req.clone();
374                replacement
375                    .headers
376                    .retain(|name, _| !name.eq_ignore_ascii_case("content-length"));
377                replacement.body = Some(alibi_core::utils::json::to_string(&body)?.into_bytes());
378                Ok(Some(alibi_core::HttpRequestAction::ReplaceRequest(
379                    Box::new(replacement),
380                )))
381            }
382            Err(detail) => Ok(Some(alibi_core::HttpRequestAction::Respond(
383                resources::to_http_response(&resources::error(400, detail, Some("invalidValue")))?,
384            ))),
385        }
386    }
387
388    route_openapi_metadata!(S);
389    fn name(&self) -> &'static str {
390        "scim"
391    }
392    fn allowed_media_types(&self, route: &AuthRoute) -> Vec<&'static str> {
393        // SCIM emits protocol-specific admission errors in its request handler.
394        if (route.path.starts_with("/scim/v2/Users/")
395            && matches!(route.method, HttpMethod::Put | HttpMethod::Patch))
396            || route.path == "/scim/v2/Users"
397            || route.path == "/scim/v2/Groups"
398            || (route.path.starts_with("/scim/v2/Groups/")
399                && matches!(route.method, HttpMethod::Put | HttpMethod::Patch))
400        {
401            Vec::new()
402        } else {
403            vec!["application/json"]
404        }
405    }
406    fn routes(&self) -> Vec<AuthRoute> {
407        vec![
408            AuthRoute::new(
409                HttpMethod::Patch,
410                "/scim/v2/Users/{userId}",
411                "patchSCIMUser",
412            ),
413            AuthRoute::put("/scim/v2/Users/{userId}", "replaceSCIMUser"),
414            AuthRoute::post("/scim/v2/Users", "createSCIMUser"),
415            AuthRoute::post("/scim/v2/Groups", "createSCIMGroup"),
416            AuthRoute::delete("/scim/v2/Users/{userId}", "deleteSCIMUser"),
417            AuthRoute::put("/scim/v2/Groups/{groupId}", "replaceSCIMGroup"),
418            AuthRoute::new(
419                HttpMethod::Patch,
420                "/scim/v2/Groups/{groupId}",
421                "patchSCIMGroup",
422            ),
423            AuthRoute::delete("/scim/v2/Groups/{groupId}", "deleteSCIMGroup"),
424            AuthRoute::get("/scim/v2/Users", "listSCIMUsers"),
425            AuthRoute::get("/scim/v2/Groups", "listSCIMGroups"),
426            AuthRoute::get("/scim/v2/Users/{userId}", "getSCIMUser"),
427            AuthRoute::get("/scim/v2/Groups/{groupId}", "getSCIMGroup"),
428            AuthRoute::get("/scim/v2/Schemas", "getSCIMSchemas"),
429            AuthRoute::get("/scim/v2/Schemas/{schemaId}", "getSCIMSchema"),
430            AuthRoute::get("/scim/v2/ResourceTypes", "getSCIMResourceTypes"),
431            AuthRoute::get(
432                "/scim/v2/ResourceTypes/{resourceTypeId}",
433                "getSCIMResourceType",
434            ),
435            AuthRoute::get(
436                "/scim/v2/ServiceProviderConfig",
437                "getSCIMServiceProviderConfig",
438            ),
439        ]
440    }
441    fn server_endpoints(&self) -> Vec<EndpointDefinition> {
442        vec![
443            crate::endpoint::definition(
444                "deleteSCIMUser",
445                "deleteSCIMUser",
446                Some("/scim/v2/Users/:userId"),
447                HttpMethod::Delete,
448            ),
449            crate::endpoint::definition(
450                "patchSCIMGroup",
451                "patchSCIMGroup",
452                Some("/scim/v2/Groups/:groupId"),
453                HttpMethod::Patch,
454            ),
455            crate::endpoint::definition(
456                "replaceSCIMGroup",
457                "replaceSCIMGroup",
458                Some("/scim/v2/Groups/:groupId"),
459                HttpMethod::Put,
460            ),
461            crate::endpoint::definition(
462                "deleteSCIMGroup",
463                "deleteSCIMGroup",
464                Some("/scim/v2/Groups/:groupId"),
465                HttpMethod::Delete,
466            ),
467            crate::endpoint::definition(
468                "patchSCIMUser",
469                "patchSCIMUser",
470                Some("/scim/v2/Users/:userId"),
471                HttpMethod::Patch,
472            ),
473            crate::endpoint::definition(
474                "replaceSCIMUser",
475                "replaceSCIMUser",
476                Some("/scim/v2/Users/:userId"),
477                HttpMethod::Put,
478            ),
479            crate::endpoint::definition(
480                "createSCIMUser",
481                "createSCIMUser",
482                Some("/scim/v2/Users"),
483                HttpMethod::Post,
484            ),
485            crate::endpoint::definition(
486                "createSCIMGroup",
487                "createSCIMGroup",
488                Some("/scim/v2/Groups"),
489                HttpMethod::Post,
490            ),
491            crate::endpoint::definition(
492                "listSCIMUsers",
493                "listSCIMUsers",
494                Some("/scim/v2/Users"),
495                HttpMethod::Get,
496            ),
497            crate::endpoint::definition(
498                "listSCIMGroups",
499                "listSCIMGroups",
500                Some("/scim/v2/Groups"),
501                HttpMethod::Get,
502            ),
503            crate::endpoint::definition(
504                "getSCIMUser",
505                "getSCIMUser",
506                Some("/scim/v2/Users/:userId"),
507                HttpMethod::Get,
508            ),
509            crate::endpoint::definition(
510                "getSCIMGroup",
511                "getSCIMGroup",
512                Some("/scim/v2/Groups/:groupId"),
513                HttpMethod::Get,
514            ),
515            crate::endpoint::definition(
516                "reconcileSCIMProjection",
517                "reconcileSCIMProjection",
518                None,
519                HttpMethod::Post,
520            ),
521            crate::endpoint::definition(
522                "decommissionSCIMManagedConnection",
523                "decommissionSCIMManagedConnection",
524                None,
525                HttpMethod::Post,
526            ),
527            crate::endpoint::definition(
528                "decommissionSCIMConnection",
529                "decommissionSCIMConnection",
530                None,
531                HttpMethod::Post,
532            ),
533            crate::endpoint::definition(
534                "listSCIMManagedConnectionEvents",
535                "listSCIMManagedConnectionEvents",
536                None,
537                HttpMethod::Post,
538            ),
539            crate::endpoint::definition(
540                "revokeSCIMManagedCredential",
541                "revokeSCIMManagedCredential",
542                None,
543                HttpMethod::Post,
544            ),
545            crate::endpoint::definition(
546                "rotateSCIMManagedCredential",
547                "rotateSCIMManagedCredential",
548                None,
549                HttpMethod::Post,
550            ),
551            crate::endpoint::definition(
552                "getSCIMManagedConnection",
553                "getSCIMManagedConnection",
554                None,
555                HttpMethod::Post,
556            ),
557            crate::endpoint::definition(
558                "listSCIMManagedConnections",
559                "listSCIMManagedConnections",
560                None,
561                HttpMethod::Post,
562            ),
563            crate::endpoint::definition(
564                "createSCIMManagedConnection",
565                "createSCIMManagedConnection",
566                None,
567                HttpMethod::Post,
568            ),
569            crate::endpoint::definition(
570                "getSCIMSchema",
571                "getSCIMSchema",
572                Some("/scim/v2/Schemas/:schemaId"),
573                HttpMethod::Get,
574            ),
575            crate::endpoint::definition(
576                "getSCIMSchemas",
577                "getSCIMSchemas",
578                Some("/scim/v2/Schemas"),
579                HttpMethod::Get,
580            ),
581            crate::endpoint::definition(
582                "getSCIMResourceType",
583                "getSCIMResourceType",
584                Some("/scim/v2/ResourceTypes/:resourceTypeId"),
585                HttpMethod::Get,
586            ),
587            crate::endpoint::definition(
588                "getSCIMResourceTypes",
589                "getSCIMResourceTypes",
590                Some("/scim/v2/ResourceTypes"),
591                HttpMethod::Get,
592            ),
593            crate::endpoint::definition(
594                "getSCIMServiceProviderConfig",
595                "getSCIMServiceProviderConfig",
596                Some("/scim/v2/ServiceProviderConfig"),
597                HttpMethod::Get,
598            ),
599        ]
600    }
601    fn validate_endpoint(
602        &self,
603        call: &EndpointCall,
604        _ctx: &AuthContext<S>,
605    ) -> AuthResult<alibi_core::endpoint::EndpointInput> {
606        if matches!(
607            call.operation_id(),
608            "reconcileSCIMProjection"
609                | "decommissionSCIMManagedConnection"
610                | "decommissionSCIMConnection"
611                | "listSCIMManagedConnectionEvents"
612                | "revokeSCIMManagedCredential"
613                | "createSCIMManagedConnection"
614                | "listSCIMManagedConnections"
615                | "getSCIMManagedConnection"
616                | "rotateSCIMManagedCredential"
617        ) {
618            managed::validate(call)
619        } else {
620            Ok(alibi_core::endpoint::EndpointInput {
621                body: call.body().cloned(),
622                query: call.query().cloned(),
623            })
624        }
625    }
626    async fn on_init(&self, _ctx: &mut alibi_core::AuthInitContext<S>) -> AuthResult<()> {
627        self.validate_connections()?;
628        if let Some(options) = &self.managed_connections {
629            if options.credential_hash_secret.encode_utf16().count() < 32 {
630                return Err(AuthError::config(
631                    "SCIM managed credentialHashSecret must contain at least 32 characters.",
632                ));
633            }
634            if !(1..=100).contains(&options.max_active_credentials) {
635                return Err(AuthError::config(
636                    "SCIM managed maxActiveCredentials must be an integer between 1 and 100.",
637                ));
638            }
639        }
640        Ok(())
641    }
642    async fn on_endpoint(
643        &self,
644        call: &EndpointCall,
645        ctx: &AuthContext<S>,
646    ) -> AuthResult<EndpointResponse> {
647        if call.operation_id() == "deleteSCIMUser" {
648            return self.delete_user(call, ctx).await;
649        }
650        if call.operation_id() == "patchSCIMGroup" {
651            return self.patch_group(call, ctx).await;
652        }
653        if call.operation_id() == "replaceSCIMGroup" {
654            return self.replace_group(call, ctx).await;
655        }
656        if call.operation_id() == "deleteSCIMGroup" {
657            return self.delete_group(call, ctx).await;
658        }
659        if call.operation_id() == "patchSCIMUser" {
660            return self.patch_user(call, ctx).await;
661        }
662        if call.operation_id() == "replaceSCIMUser" {
663            return self.replace_user(call, ctx).await;
664        }
665        if call.operation_id() == "createSCIMUser" {
666            return self.create_user(call, ctx).await;
667        }
668        if call.operation_id() == "createSCIMGroup" {
669            return self.create_group(call, ctx).await;
670        }
671        if call.operation_id() == "listSCIMUsers" {
672            return self.list_users(call, ctx).await;
673        }
674        if call.operation_id() == "listSCIMGroups" {
675            return self.list_groups(call, ctx).await;
676        }
677        if call.operation_id() == "getSCIMUser" {
678            return self.get_user(call, ctx).await;
679        }
680        if call.operation_id() == "getSCIMGroup" {
681            return self.get_group(call, ctx).await;
682        }
683        if call.operation_id() == "reconcileSCIMProjection" {
684            return self.reconcile_projection(call, ctx).await;
685        }
686        if call.operation_id() == "decommissionSCIMManagedConnection" {
687            return self.decommission_managed_connection(call, ctx).await;
688        }
689        if call.operation_id() == "decommissionSCIMConnection" {
690            return self.decommission_connection(call, ctx).await;
691        }
692        if call.operation_id() == "listSCIMManagedConnectionEvents" {
693            return self.list_managed_connection_events(call, ctx).await;
694        }
695        if call.operation_id() == "revokeSCIMManagedCredential" {
696            return self.revoke_managed_credential(call, ctx).await;
697        }
698        if call.operation_id() == "rotateSCIMManagedCredential" {
699            return self.rotate_managed_credential(call, ctx).await;
700        }
701        if call.operation_id() == "getSCIMManagedConnection" {
702            return self.get_managed_connection(call, ctx).await;
703        }
704        if call.operation_id() == "listSCIMManagedConnections" {
705            return self.list_managed_connections(call, ctx).await;
706        }
707        if call.operation_id() == "createSCIMManagedConnection" {
708            return self.create_managed_connection(call, ctx).await;
709        }
710        if call.operation_id() == "getSCIMSchema" {
711            return Self::schema(
712                ctx,
713                call.params()
714                    .and_then(|params| params.get("schemaId"))
715                    .and_then(alibi_core::utils::json::JsValue::as_str)
716                    .unwrap_or(""),
717            );
718        }
719        if call.operation_id() == "getSCIMSchemas" {
720            return Ok(EndpointResponse::json(&Self::schemas(ctx)?)?
721                .with_header("x-better-auth-scim-response", "1"));
722        }
723        if call.operation_id() == "getSCIMResourceType" {
724            return Self::resource_type(
725                ctx,
726                call.params()
727                    .and_then(|params| params.get("resourceTypeId"))
728                    .and_then(alibi_core::utils::json::JsValue::as_str),
729            );
730        }
731        let value = if call.operation_id() == "getSCIMResourceTypes" {
732            Self::resource_types(ctx)
733        } else {
734            Self::service_provider_config(ctx)
735        };
736        Ok(EndpointResponse::json(&value)?.with_header("x-better-auth-scim-response", "1"))
737    }
738    async fn on_request(
739        &self,
740        req: &AuthRequest,
741        ctx: &AuthContext<S>,
742    ) -> AuthResult<Option<AuthResponse>> {
743        if req.method() == &HttpMethod::Patch
744            && let Some(id) = req.path().strip_prefix("/scim/v2/Users/")
745        {
746            let call = EndpointCall::from_http_request(
747                req,
748                &AuthRoute::new(
749                    HttpMethod::Patch,
750                    "/scim/v2/Users/{userId}",
751                    "patchSCIMUser",
752                ),
753            )
754            .with_params_value(alibi_core::utils::json::JsValue::from(json!({"userId":id})));
755            let response = if let Some(error) = resources::mutation_request_error(req, &call) {
756                error
757            } else {
758                self.patch_user(&call, ctx).await?
759            };
760            return Ok(Some(resources::to_http_response(&response)?));
761        }
762        if req.method() == &HttpMethod::Put
763            && let Some(id) = req.path().strip_prefix("/scim/v2/Users/")
764        {
765            let call = EndpointCall::from_http_request(
766                req,
767                &AuthRoute::put("/scim/v2/Users/{userId}", "replaceSCIMUser"),
768            )
769            .with_params_value(alibi_core::utils::json::JsValue::from(json!({"userId":id})));
770            let response = if let Some(error) = resources::mutation_request_error(req, &call) {
771                error
772            } else {
773                self.replace_user(&call, ctx).await?
774            };
775            return Ok(Some(resources::to_http_response(&response)?));
776        }
777        if req.method() == &HttpMethod::Delete
778            && let Some(id) = req.path().strip_prefix("/scim/v2/Users/")
779        {
780            let call = EndpointCall::from_http_request(
781                req,
782                &AuthRoute::delete("/scim/v2/Users/{userId}", "deleteSCIMUser"),
783            )
784            .with_params_value(alibi_core::utils::json::JsValue::from(json!({"userId":id})));
785            let response = self.delete_user(&call, ctx).await?;
786            return Ok(Some(resources::to_http_response(&response)?));
787        }
788        if matches!(req.method(), HttpMethod::Put | HttpMethod::Patch)
789            && let Some(id) = req.path().strip_prefix("/scim/v2/Groups/")
790        {
791            let route = if req.method() == &HttpMethod::Put {
792                AuthRoute::put("/scim/v2/Groups/{groupId}", "replaceSCIMGroup")
793            } else {
794                AuthRoute::new(
795                    HttpMethod::Patch,
796                    "/scim/v2/Groups/{groupId}",
797                    "patchSCIMGroup",
798                )
799            };
800            let call = EndpointCall::from_http_request(req, &route).with_params_value(
801                alibi_core::utils::json::JsValue::from(json!({"groupId":id})),
802            );
803            let response = match resources::mutation_request_error(req, &call) {
804                Some(response) => response,
805                None => {
806                    if req.method() == &HttpMethod::Put {
807                        self.replace_group(&call, ctx).await?
808                    } else {
809                        self.patch_group(&call, ctx).await?
810                    }
811                }
812            };
813            return Ok(Some(resources::to_http_response(&response)?));
814        }
815        if req.method() == &HttpMethod::Delete
816            && let Some(id) = req.path().strip_prefix("/scim/v2/Groups/")
817        {
818            let call = EndpointCall::from_http_request(
819                req,
820                &AuthRoute::delete("/scim/v2/Groups/{groupId}", "deleteSCIMGroup"),
821            )
822            .with_params_value(alibi_core::utils::json::JsValue::from(
823                json!({"groupId":id}),
824            ));
825            let mut response = self.delete_group(&call, ctx).await?.to_response()?;
826            let mut headers = alibi_core::Headers::new();
827            for (name, value) in response.headers {
828                if !name.eq_ignore_ascii_case("x-better-auth-scim-response") {
829                    headers.append(name, value);
830                }
831            }
832            response.headers = headers;
833            return Ok(Some(
834                response.with_header("content-type", "application/scim+json"),
835            ));
836        }
837        if req.method() == &HttpMethod::Post && req.path() == "/scim/v2/Users" {
838            let call = EndpointCall::from_http_request(
839                req,
840                &AuthRoute::post("/scim/v2/Users", "createSCIMUser"),
841            );
842            let response = if let Some(error) = resources::mutation_request_error(req, &call) {
843                error
844            } else {
845                self.create_user(&call, ctx).await?
846            };
847            return Ok(Some(resources::to_http_response(&response)?));
848        }
849        if req.method() == &HttpMethod::Post && req.path() == "/scim/v2/Groups" {
850            let call = EndpointCall::from_http_request(
851                req,
852                &AuthRoute::post("/scim/v2/Groups", "createSCIMGroup"),
853            );
854            let media = req
855                .headers
856                .get("content-type")
857                .and_then(|value| value.split(';').next())
858                .map(str::trim);
859            let response = if !media.is_some_and(|media| {
860                media.eq_ignore_ascii_case("application/json")
861                    || media.eq_ignore_ascii_case("application/scim+json")
862            }) {
863                resources::error(
864                    415,
865                    "SCIM requests must use application/scim+json or application/json",
866                    None,
867                )
868            } else if call.body().is_none() {
869                resources::error(
870                    400,
871                    "SCIM request body must contain valid JSON",
872                    Some("invalidSyntax"),
873                )
874            } else {
875                self.create_group(&call, ctx).await?
876            };
877            let mut response = response.to_response()?;
878            let mut headers = alibi_core::Headers::new();
879            for (name, value) in response.headers {
880                if !name.eq_ignore_ascii_case("x-better-auth-scim-response") {
881                    headers.append(name, value);
882                }
883            }
884            response.headers = headers;
885            return Ok(Some(
886                response.with_header("content-type", "application/scim+json"),
887            ));
888        }
889        if req.method() == &HttpMethod::Get && req.path() == "/scim/v2/Users" {
890            let call = EndpointCall::from_http_request(
891                req,
892                &AuthRoute::get("/scim/v2/Users", "listSCIMUsers"),
893            );
894            let mut response = self.list_users(&call, ctx).await?.to_response()?;
895            let mut headers = alibi_core::Headers::new();
896            for (name, value) in response.headers {
897                if !name.eq_ignore_ascii_case("x-better-auth-scim-response") {
898                    headers.append(name, value);
899                }
900            }
901            response.headers = headers;
902            return Ok(Some(
903                response.with_header("content-type", "application/scim+json"),
904            ));
905        }
906        if req.method() == &HttpMethod::Get && req.path() == "/scim/v2/Groups" {
907            let call = EndpointCall::from_http_request(
908                req,
909                &AuthRoute::get("/scim/v2/Groups", "listSCIMGroups"),
910            );
911            let mut response = self.list_groups(&call, ctx).await?.to_response()?;
912            let mut headers = alibi_core::Headers::new();
913            for (name, value) in response.headers {
914                if !name.eq_ignore_ascii_case("x-better-auth-scim-response") {
915                    headers.append(name, value);
916                }
917            }
918            response.headers = headers;
919            return Ok(Some(
920                response.with_header("content-type", "application/scim+json"),
921            ));
922        }
923        if req.method() == &HttpMethod::Get
924            && let Some(id) = req.path().strip_prefix("/scim/v2/Users/")
925        {
926            let call = EndpointCall::from_http_request(
927                req,
928                &AuthRoute::get("/scim/v2/Users/{userId}", "getSCIMUser"),
929            )
930            .with_params_value(alibi_core::utils::json::JsValue::from(json!({"userId":id})));
931            let mut response = self.get_user(&call, ctx).await?.to_response()?;
932            let mut headers = alibi_core::Headers::new();
933            for (name, value) in response.headers {
934                if !name.eq_ignore_ascii_case("x-better-auth-scim-response") {
935                    headers.append(name, value);
936                }
937            }
938            response.headers = headers;
939            return Ok(Some(
940                response.with_header("content-type", "application/scim+json"),
941            ));
942        }
943        if req.method() == &HttpMethod::Get
944            && let Some(id) = req.path().strip_prefix("/scim/v2/Groups/")
945        {
946            let call = EndpointCall::from_http_request(
947                req,
948                &AuthRoute::get("/scim/v2/Groups/{groupId}", "getSCIMGroup"),
949            )
950            .with_params_value(alibi_core::utils::json::JsValue::from(
951                json!({"groupId":id}),
952            ));
953            let mut response = self.get_group(&call, ctx).await?.to_response()?;
954            let mut headers = alibi_core::Headers::new();
955            for (name, value) in response.headers {
956                if !name.eq_ignore_ascii_case("x-better-auth-scim-response") {
957                    headers.append(name, value);
958                }
959            }
960            response.headers = headers;
961            return Ok(Some(
962                response.with_header("content-type", "application/scim+json"),
963            ));
964        }
965        if req.method() == &HttpMethod::Get
966            && let Some(id) = req.path().strip_prefix("/scim/v2/Schemas/")
967        {
968            return Ok(Some(resources::to_http_response(&Self::schema(ctx, id)?)?));
969        }
970        if req.method() == &HttpMethod::Get
971            && let Some(id) = req.path().strip_prefix("/scim/v2/ResourceTypes/")
972        {
973            return Ok(Some(resources::to_http_response(&Self::resource_type(
974                ctx,
975                Some(id),
976            )?)?));
977        }
978        if req.method() != &HttpMethod::Get
979            || !matches!(
980                req.path(),
981                "/scim/v2/ServiceProviderConfig" | "/scim/v2/ResourceTypes" | "/scim/v2/Schemas"
982            )
983        {
984            return Ok(None);
985        }
986        let value = if req.path() == "/scim/v2/Schemas" {
987            Self::schemas(ctx)?
988        } else if req.path() == "/scim/v2/ResourceTypes" {
989            Self::resource_types(ctx)
990        } else {
991            Self::service_provider_config(ctx)
992        };
993        Ok(Some(
994            AuthResponse::json(200, &value)?.with_header("content-type", "application/scim+json"),
995        ))
996    }
997}