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