1use 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#[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#[derive(Clone, Debug)]
59pub struct ScimConnectionOptions {
60 pub id: String,
61 pub provisioning_domain_id: Option<String>,
62 pub credentials: Vec<ScimCredential>,
63}
64#[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#[derive(Clone, Debug, Default)]
94pub struct ScimCompatibilityOptions {
95 pub microsoft_entra: Option<ScimMicrosoftEntraCompatibilityOptions>,
96}
97#[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 #[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 pub fn delete_user_endpoint(id: &str) -> AuthResult<ServerEndpoint<()>> {
142 ServerEndpoint::new("scim", "deleteSCIMUser").with_params(&json!({"userId":id}))
143 }
144 pub fn patch_group_endpoint(id: &str) -> AuthResult<ServerEndpoint<Value>> {
148 ServerEndpoint::new("scim", "patchSCIMGroup").with_params(&json!({"groupId":id}))
149 }
150 pub fn replace_group_endpoint(id: &str) -> AuthResult<ServerEndpoint<Value>> {
154 ServerEndpoint::new("scim", "replaceSCIMGroup").with_params(&json!({"groupId":id}))
155 }
156 pub fn delete_group_endpoint(id: &str) -> AuthResult<ServerEndpoint<()>> {
160 ServerEndpoint::new("scim", "deleteSCIMGroup").with_params(&json!({"groupId":id}))
161 }
162 pub fn patch_user_endpoint(id: &str) -> AuthResult<ServerEndpoint<Value>> {
166 ServerEndpoint::new("scim", "patchSCIMUser").with_params(&json!({"userId":id}))
167 }
168 pub fn replace_user_endpoint(id: &str) -> AuthResult<ServerEndpoint<Value>> {
172 ServerEndpoint::new("scim", "replaceSCIMUser").with_params(&json!({"userId":id}))
173 }
174 #[must_use]
176 pub const fn create_user_endpoint() -> ServerEndpoint<Value> {
177 ServerEndpoint::new("scim", "createSCIMUser")
178 }
179 #[must_use]
181 pub const fn create_group_endpoint() -> ServerEndpoint<Value> {
182 ServerEndpoint::new("scim", "createSCIMGroup")
183 }
184 #[must_use]
186 pub const fn users_endpoint() -> ServerEndpoint<Value> {
187 ServerEndpoint::new("scim", "listSCIMUsers")
188 }
189 #[must_use]
191 pub const fn groups_endpoint() -> ServerEndpoint<Value> {
192 ServerEndpoint::new("scim", "listSCIMGroups")
193 }
194 pub fn user_endpoint(id: &str) -> AuthResult<ServerEndpoint<Value>> {
198 ServerEndpoint::new("scim", "getSCIMUser").with_params(&json!({"userId":id}))
199 }
200 pub fn group_endpoint(id: &str) -> AuthResult<ServerEndpoint<Value>> {
204 ServerEndpoint::new("scim", "getSCIMGroup").with_params(&json!({"groupId":id}))
205 }
206 #[must_use]
208 pub const fn service_provider_config_endpoint() -> ServerEndpoint<Value> {
209 ServerEndpoint::new("scim", "getSCIMServiceProviderConfig")
210 }
211 #[must_use]
213 pub const fn resource_types_endpoint() -> ServerEndpoint<Value> {
214 ServerEndpoint::new("scim", "getSCIMResourceTypes")
215 }
216 #[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 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 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 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}
314fn 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 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}