use std::net::SocketAddr;
use std::sync::Arc;
use serde_json::{Value, json};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tonic::Request;
use crate::proto::udb::core::common::v1 as common_pb;
use crate::proto::udb::core::idp::services::v1 as idp_pb;
use idp_pb::identity_provider_service_server::IdentityProviderService;
use super::IdentityProviderServiceImpl;
struct ScimHttpConfig {
addr: SocketAddr,
bearer_token: String,
default_tenant: String,
default_provider: String,
}
impl std::fmt::Debug for ScimHttpConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ScimHttpConfig")
.field("addr", &self.addr)
.field("bearer_token", &"[redacted]")
.field("default_tenant", &self.default_tenant)
.field("default_provider", &self.default_provider)
.finish()
}
}
impl ScimHttpConfig {
fn from_env() -> Option<Self> {
let raw_addr = std::env::var("UDB_SCIM_HTTP_ADDR").ok()?;
let addr: SocketAddr = match raw_addr.trim().parse() {
Ok(addr) => addr,
Err(err) => {
tracing::warn!(value = %raw_addr, error = %err, "invalid UDB_SCIM_HTTP_ADDR; SCIM HTTP disabled");
return None;
}
};
let bearer_token = std::env::var("UDB_SCIM_BEARER_TOKEN")
.ok()
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.or_else(|| {
tracing::warn!(
"UDB_SCIM_HTTP_ADDR is set but UDB_SCIM_BEARER_TOKEN is empty; \
SCIM HTTP refuses to start unauthenticated (fail closed)"
);
None
})?;
Some(Self {
addr,
bearer_token,
default_tenant: std::env::var("UDB_SCIM_DEFAULT_TENANT")
.unwrap_or_default()
.trim()
.to_string(),
default_provider: std::env::var("UDB_SCIM_DEFAULT_PROVIDER")
.unwrap_or_default()
.trim()
.to_string(),
})
}
}
pub(crate) fn spawn_from_env_with_shutdown<F>(
service: Arc<IdentityProviderServiceImpl>,
shutdown: F,
) -> Option<tokio::task::JoinHandle<()>>
where
F: std::future::Future<Output = ()> + Send + 'static,
{
let cfg = ScimHttpConfig::from_env()?;
tracing::info!(addr = %cfg.addr, "SCIM 2.0 HTTP surface enabled");
Some(tokio::spawn(async move {
tokio::select! {
_ = serve(service, cfg) => {}
_ = shutdown => {
tracing::info!("SCIM HTTP listener shutting down");
}
}
}))
}
async fn serve(service: Arc<IdentityProviderServiceImpl>, cfg: ScimHttpConfig) {
let listener = match tokio::net::TcpListener::bind(cfg.addr).await {
Ok(l) => l,
Err(err) => {
tracing::warn!(addr = %cfg.addr, error = %err, "SCIM HTTP endpoint disabled");
return;
}
};
let cfg = Arc::new(cfg);
loop {
let Ok((mut socket, _peer)) = listener.accept().await else {
continue;
};
let service = service.clone();
let cfg = cfg.clone();
tokio::spawn(async move {
let response = match read_request(&mut socket).await {
Some(req) => dispatch(service.as_ref(), cfg.as_ref(), req).await,
None => http_response(400, &scim_error(400, "malformed HTTP request")),
};
let _ = socket.write_all(response.as_bytes()).await;
});
}
}
struct HttpRequest {
method: String,
path: String,
query: String,
bearer: String,
body: String,
}
async fn read_request(socket: &mut tokio::net::TcpStream) -> Option<HttpRequest> {
const MAX: usize = 256 * 1024;
let mut buf: Vec<u8> = Vec::with_capacity(8192);
let mut chunk = [0u8; 8192];
let header_end = loop {
let n = tokio::time::timeout(std::time::Duration::from_secs(10), socket.read(&mut chunk))
.await
.ok()?
.ok()?;
if n == 0 {
break find_header_end(&buf);
}
buf.extend_from_slice(&chunk[..n]);
if let Some(end) = find_header_end(&buf) {
break Some(end);
}
if buf.len() > MAX {
return None;
}
}?;
let head = String::from_utf8_lossy(&buf[..header_end]).to_string();
let mut lines = head.lines();
let request_line = lines.next()?;
let mut parts = request_line.split_whitespace();
let method = parts.next()?.to_string();
let raw_target = parts.next()?.to_string();
let (path, query) = match raw_target.split_once('?') {
Some((p, q)) => (p.to_string(), q.to_string()),
None => (raw_target, String::new()),
};
let mut bearer = String::new();
let mut content_length = 0usize;
for line in lines {
if let Some((name, value)) = line.split_once(':') {
let name = name.trim().to_ascii_lowercase();
let value = value.trim();
if name == "authorization" {
if let Some(tok) = value
.strip_prefix("Bearer ")
.or_else(|| value.strip_prefix("bearer "))
{
bearer = tok.trim().to_string();
}
} else if name == "content-length" {
content_length = value.parse().unwrap_or(0);
}
}
}
let body_start = header_end + 4; let mut body_bytes: Vec<u8> = buf
.get(body_start..)
.map(|s| s.to_vec())
.unwrap_or_default();
while body_bytes.len() < content_length.min(MAX) {
let n = tokio::time::timeout(std::time::Duration::from_secs(10), socket.read(&mut chunk))
.await
.ok()?
.ok()?;
if n == 0 {
break;
}
body_bytes.extend_from_slice(&chunk[..n]);
}
let body = String::from_utf8_lossy(&body_bytes).to_string();
Some(HttpRequest {
method,
path,
query,
bearer,
body,
})
}
fn find_header_end(buf: &[u8]) -> Option<usize> {
buf.windows(4).position(|w| w == b"\r\n\r\n")
}
async fn dispatch(
service: &IdentityProviderServiceImpl,
cfg: &ScimHttpConfig,
req: HttpRequest,
) -> String {
let Some(rest) = req.path.strip_prefix("/scim/v2") else {
return http_response(404, &scim_error(404, "not a SCIM endpoint"));
};
let rest = rest.trim_start_matches('/');
let (tenant, provider, resource_path) = resolve_scope(rest, cfg);
match (req.method.as_str(), resource_path.as_str()) {
("GET", "ServiceProviderConfig") => {
return http_response(200, &service_provider_config());
}
("GET", "ResourceTypes") => {
return http_response(200, &resource_types());
}
("GET", "Schemas") => {
return http_response(200, &schemas());
}
_ => {}
}
if req.bearer.is_empty() || !bearer_matches(&req.bearer, &cfg.bearer_token) {
return http_response(401, &scim_error(401, "invalid or missing bearer token"));
}
if tenant.is_empty() || provider.is_empty() {
return http_response(
400,
&scim_error(
400,
"tenant/provider unresolved: use /scim/v2/t/{tenant}/p/{provider}/... \
or set UDB_SCIM_DEFAULT_TENANT/UDB_SCIM_DEFAULT_PROVIDER",
),
);
}
let mut segs = resource_path.splitn(2, '/');
let collection = segs.next().unwrap_or_default();
let id = segs.next().unwrap_or_default().trim_end_matches('/');
match (req.method.as_str(), collection, id.is_empty()) {
("POST", "Users", true) => {
let r = idp_pb::ScimCreateUserRequest {
tenant_id: tenant,
provider_id: provider,
scim_user_json: req.body,
context: None,
};
match service.scim_create_user(Request::new(r)).await {
Ok(resp) => user_response(201, resp.into_inner().user),
Err(s) => status_response(s),
}
}
("GET", "Users", true) => {
let r = idp_pb::ScimListUsersRequest {
tenant_id: tenant,
provider_id: provider,
filter: query_param(&req.query, "filter"),
page: Some(page_from_query(&req.query)),
};
match service.scim_list_users(Request::new(r)).await {
Ok(resp) => {
let inner = resp.into_inner();
let total = inner
.page
.map(|p| p.total_count)
.unwrap_or(inner.users.len() as i64);
list_response(inner.users.iter().map(user_to_json).collect(), total)
}
Err(s) => status_response(s),
}
}
("GET", "Users", false) => {
let r = idp_pb::ScimGetUserRequest {
tenant_id: tenant,
provider_id: provider,
scim_user_id: id.to_string(),
};
match service.scim_get_user(Request::new(r)).await {
Ok(resp) => user_response(200, resp.into_inner().user),
Err(s) => status_response(s),
}
}
("PUT", "Users", false) => {
let r = idp_pb::ScimReplaceUserRequest {
tenant_id: tenant,
provider_id: provider,
scim_user_id: id.to_string(),
scim_user_json: req.body,
context: None,
};
match service.scim_replace_user(Request::new(r)).await {
Ok(resp) => user_response(200, resp.into_inner().user),
Err(s) => status_response(s),
}
}
("PATCH", "Users", false) => {
let ops = match parse_patch_ops(&req.body) {
Ok(ops) => ops,
Err(msg) => return http_response(400, &scim_error(400, &msg)),
};
let r = idp_pb::ScimPatchUserRequest {
tenant_id: tenant,
provider_id: provider,
scim_user_id: id.to_string(),
operations: ops,
context: None,
};
match service.scim_patch_user(Request::new(r)).await {
Ok(resp) => user_response(200, resp.into_inner().user),
Err(s) => status_response(s),
}
}
("DELETE", "Users", false) => {
let r = idp_pb::ScimDeleteUserRequest {
tenant_id: tenant,
provider_id: provider,
scim_user_id: id.to_string(),
context: None,
};
match service.scim_delete_user(Request::new(r)).await {
Ok(_) => http_no_content(),
Err(s) => status_response(s),
}
}
("POST", "Groups", true) => {
let r = idp_pb::ScimCreateGroupRequest {
tenant_id: tenant,
provider_id: provider,
scim_group_json: req.body,
context: None,
};
match service.scim_create_group(Request::new(r)).await {
Ok(resp) => group_response(201, resp.into_inner().group),
Err(s) => status_response(s),
}
}
("GET", "Groups", true) => {
let r = idp_pb::ScimListGroupsRequest {
tenant_id: tenant,
provider_id: provider,
filter: query_param(&req.query, "filter"),
page: Some(page_from_query(&req.query)),
};
match service.scim_list_groups(Request::new(r)).await {
Ok(resp) => {
let inner = resp.into_inner();
let total = inner
.page
.map(|p| p.total_count)
.unwrap_or(inner.groups.len() as i64);
list_response(inner.groups.iter().map(group_to_json).collect(), total)
}
Err(s) => status_response(s),
}
}
("GET", "Groups", false) => {
let r = idp_pb::ScimGetGroupRequest {
tenant_id: tenant,
provider_id: provider,
scim_group_id: id.to_string(),
};
match service.scim_get_group(Request::new(r)).await {
Ok(resp) => group_response(200, resp.into_inner().group),
Err(s) => status_response(s),
}
}
("PATCH", "Groups", false) => {
let ops = match parse_patch_ops(&req.body) {
Ok(ops) => ops,
Err(msg) => return http_response(400, &scim_error(400, &msg)),
};
let r = idp_pb::ScimPatchGroupRequest {
tenant_id: tenant,
provider_id: provider,
scim_group_id: id.to_string(),
operations: ops,
context: None,
};
match service.scim_patch_group(Request::new(r)).await {
Ok(resp) => group_response(200, resp.into_inner().group),
Err(s) => status_response(s),
}
}
("DELETE", "Groups", false) => {
let r = idp_pb::ScimDeleteGroupRequest {
tenant_id: tenant,
provider_id: provider,
scim_group_id: id.to_string(),
context: None,
};
match service.scim_delete_group(Request::new(r)).await {
Ok(_) => http_no_content(),
Err(s) => status_response(s),
}
}
_ => http_response(404, &scim_error(404, "unknown SCIM resource or method")),
}
}
fn resolve_scope(rest: &str, cfg: &ScimHttpConfig) -> (String, String, String) {
let segs: Vec<&str> = rest.split('/').collect();
if segs.len() >= 4 && segs[0] == "t" && segs[2] == "p" {
let tenant = segs[1].to_string();
let provider = segs[3].to_string();
let resource = segs[4..].join("/");
return (tenant, provider, resource);
}
(
cfg.default_tenant.clone(),
cfg.default_provider.clone(),
rest.to_string(),
)
}
fn bearer_matches(presented: &str, expected: &str) -> bool {
let a = presented.as_bytes();
let b = expected.as_bytes();
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
fn parse_patch_ops(body: &str) -> Result<Vec<idp_pb::ScimPatchOp>, String> {
let v: Value =
serde_json::from_str(body.trim()).map_err(|e| format!("invalid PATCH body JSON: {e}"))?;
let ops = v
.get("Operations")
.or_else(|| v.get("operations"))
.and_then(Value::as_array)
.ok_or_else(|| "PATCH body missing 'Operations' array".to_string())?;
Ok(ops
.iter()
.map(|o| idp_pb::ScimPatchOp {
op: o
.get("op")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
path: o
.get("path")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
value_json: o
.get("value")
.map(|val| val.to_string())
.unwrap_or_else(|| "null".to_string()),
})
.collect())
}
fn query_param(query: &str, key: &str) -> String {
for pair in query.split('&') {
if let Some((k, v)) = pair.split_once('=') {
if k == key {
return urlencoding::decode(v)
.map(|c| c.into_owned())
.unwrap_or_default();
}
}
}
String::new()
}
fn page_from_query(query: &str) -> common_pb::PageRequest {
let count = query_param(query, "count").parse::<i32>().unwrap_or(0);
let start_index = query_param(query, "startIndex")
.parse::<i32>()
.unwrap_or(1)
.max(1);
let page_size = if count > 0 { count } else { 0 };
let page = if page_size > 0 {
((start_index - 1) / page_size) + 1
} else {
1
};
common_pb::PageRequest {
page,
page_size,
page_token: String::new(),
}
}
fn user_to_json(u: &idp_pb::ScimUser) -> Value {
if !u.raw_json.trim().is_empty() {
if let Ok(v) = serde_json::from_str::<Value>(&u.raw_json) {
return v;
}
}
json!({
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"id": u.id,
"userName": u.user_name,
"displayName": u.display_name,
"active": u.active,
"emails": [{ "value": u.email, "primary": true }],
})
}
fn group_to_json(g: &idp_pb::ScimGroup) -> Value {
if !g.raw_json.trim().is_empty() {
if let Ok(v) = serde_json::from_str::<Value>(&g.raw_json) {
return v;
}
}
json!({
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
"id": g.id,
"displayName": g.display_name,
"members": g.members,
})
}
fn user_response(status: u16, user: Option<idp_pb::ScimUser>) -> String {
match user {
Some(u) => {
let location =
(status == 201 && !u.id.is_empty()).then(|| format!("/scim/v2/Users/{}", u.id));
http_response_with_location(status, &user_to_json(&u).to_string(), location)
}
None => http_response(500, &scim_error(500, "empty SCIM user response")),
}
}
fn group_response(status: u16, group: Option<idp_pb::ScimGroup>) -> String {
match group {
Some(g) => {
let location =
(status == 201 && !g.id.is_empty()).then(|| format!("/scim/v2/Groups/{}", g.id));
http_response_with_location(status, &group_to_json(&g).to_string(), location)
}
None => http_response(500, &scim_error(500, "empty SCIM group response")),
}
}
fn list_response(resources: Vec<Value>, total: i64) -> String {
let body = json!({
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
"totalResults": total,
"startIndex": 1,
"itemsPerPage": resources.len(),
"Resources": resources,
});
http_response(200, &body.to_string())
}
fn status_response(status: tonic::Status) -> String {
use tonic::Code;
let http = match status.code() {
Code::InvalidArgument | Code::FailedPrecondition => 400,
Code::Unauthenticated => 401,
Code::PermissionDenied => 403,
Code::NotFound => 404,
Code::AlreadyExists => 409,
Code::Unavailable => 503,
_ => 500,
};
http_response(http, &scim_error(http, status.message()))
}
fn scim_error(status: u16, detail: &str) -> String {
json!({
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"status": status.to_string(),
"detail": detail,
})
.to_string()
}
fn service_provider_config() -> String {
json!({
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
"documentationUri": "https://github.com/fahara02/udb",
"patch": { "supported": true },
"bulk": { "supported": false, "maxOperations": 0, "maxPayloadSize": 0 },
"filter": { "supported": false, "maxResults": 0 },
"changePassword": { "supported": false },
"sort": { "supported": false },
"etag": { "supported": false },
"authenticationSchemes": [{
"type": "oauthbearertoken",
"name": "OAuth Bearer Token",
"description": "Static bearer token presented in the Authorization header.",
"primary": true
}]
})
.to_string()
}
fn resource_types() -> String {
json!([
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
"id": "User",
"name": "User",
"endpoint": "/Users",
"schema": "urn:ietf:params:scim:schemas:core:2.0:User"
},
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
"id": "Group",
"name": "Group",
"endpoint": "/Groups",
"schema": "urn:ietf:params:scim:schemas:core:2.0:Group"
}
])
.to_string()
}
fn schemas() -> String {
json!([
{ "id": "urn:ietf:params:scim:schemas:core:2.0:User", "name": "User" },
{ "id": "urn:ietf:params:scim:schemas:core:2.0:Group", "name": "Group" }
])
.to_string()
}
fn http_response(status: u16, body: &str) -> String {
http_response_with_location(status, body, None)
}
fn http_response_with_location(status: u16, body: &str, location: Option<String>) -> String {
let reason = match status {
200 => "OK",
201 => "Created",
400 => "Bad Request",
401 => "Unauthorized",
403 => "Forbidden",
404 => "Not Found",
409 => "Conflict",
500 => "Internal Server Error",
503 => "Service Unavailable",
_ => "OK",
};
let mut head = format!(
"HTTP/1.1 {status} {reason}\r\ncontent-type: application/scim+json; charset=utf-8\r\ncontent-length: {}\r\nconnection: close\r\n",
body.len()
);
if status == 401 {
head.push_str("www-authenticate: Bearer\r\n");
}
if let Some(loc) = location {
head.push_str(&format!("location: {loc}\r\n"));
}
head.push_str("\r\n");
head.push_str(body);
head
}
fn http_no_content() -> String {
"HTTP/1.1 204 No Content\r\ncontent-length: 0\r\nconnection: close\r\n\r\n".to_string()
}
#[cfg(test)]
mod scim_http_redaction_tests {
use super::*;
#[test]
fn scim_http_config_debug_never_leaks_bearer_token() {
let cfg = ScimHttpConfig {
addr: "127.0.0.1:9999".parse().expect("valid addr"),
bearer_token: "udb-canary-SCIM-SECRET".to_string(),
default_tenant: "acme".to_string(),
default_provider: "okta".to_string(),
};
let dbg = format!("{cfg:?}");
assert!(
!dbg.contains("udb-canary-SCIM-SECRET"),
"ScimHttpConfig Debug leaked the bearer token: {dbg}"
);
assert!(
dbg.contains("[redacted]"),
"expected redaction marker: {dbg}"
);
assert!(
dbg.contains("acme"),
"default_tenant should remain visible: {dbg}"
);
assert!(
dbg.contains("okta"),
"default_provider should remain visible: {dbg}"
);
}
}