From ac3a63973dd618421df858cc97e764cb7697208f Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 28 Sep 2026 12:38:58 -0700 Subject: [PATCH] Try a directory before anything signs in through it POST /api/directory/test takes a saved directory's id, an address and optionally a password, and answers whether the directory opened, what a recipient lookup of the address finds (account or group, with its aliases, groups and name), and whether the password signs in. A wrong password is told apart from a directory that can't be reached or is set up wrong. It calls the directory itself, below the sign-in path: a test never creates or updates an account, never counts toward the sign-in ban and doesn't depend on which domains use the directory. A password hash a directory returns is never sent back. OIDC directories report their discovered issuer; they take no passwords. For server-level administrators with directory update permission. The console's guided directory setup uses it to test a real person before any domain is switched over. --- crates/http/src/api/mod.rs | 11 ++ crates/jmap/src/inbuxa/directory_test.rs | 156 +++++++++++++++++++++++ crates/jmap/src/inbuxa/mod.rs | 1 + 3 files changed, 168 insertions(+) create mode 100644 crates/jmap/src/inbuxa/directory_test.rs diff --git a/crates/http/src/api/mod.rs b/crates/http/src/api/mod.rs index 1730bb8..b35a721 100644 --- a/crates/http/src/api/mod.rs +++ b/crates/http/src/api/mod.rs @@ -120,6 +120,17 @@ impl ManagementApi for Server { jmap::inbuxa::explanation::question(self, &access_token, &subject).await?; Ok(explain_stream(self.clone(), access_token, question, in_flight)) } + // inbuxa: try a saved directory before anything signs in through it + "directory" if is_post && path.get(1).copied() == Some("test") => { + let (_in_flight, access_token) = self.authenticate_headers(req, session).await?; + jmap::inbuxa::directory_test::assert_allowed(&access_token)?; + let request = body + .as_deref() + .and_then(|body| serde_json::from_slice::(body).ok()) + .unwrap_or_default(); + let answer = jmap::inbuxa::directory_test::test(self, &request).await?; + Ok(JsonResponse::new(answer).no_cache().into_http_response()) + } "account" => { // Authenticate request let (_in_flight, access_token) = self.authenticate_headers(req, session).await?; diff --git a/crates/jmap/src/inbuxa/directory_test.rs b/crates/jmap/src/inbuxa/directory_test.rs new file mode 100644 index 0000000..cfc6058 --- /dev/null +++ b/crates/jmap/src/inbuxa/directory_test.rs @@ -0,0 +1,156 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `POST /api/directory/test`: try a saved directory before anything signs in +//! through it (settings-reorg, guided setup "Connect a sign-in directory"). +//! +//! The body names a directory and an address, and optionally a password: +//! +//! ```json +//! {"directoryId": "b", "address": "jane@corp.example", "password": "…"} +//! ``` +//! +//! The answer says whether the directory opened, what a recipient lookup of +//! the address finds, and, when a password is given, whether it signs in. +//! It calls the directory itself, below the sign-in path, so a test never +//! creates or updates an account (DIR-14), never counts toward the sign-in +//! ban, and doesn't mind which domains use the directory (DIR-6). Nothing is +//! cached (DIR-32). A password hash a directory hands back is never returned. +//! +//! For server-level administrators who may change directories. + +use common::{Server, auth::AccessToken}; +use directory::{Credentials, Directory, Recipient}; +use registry::schema::enums::Permission; +use serde_json::{Value, json}; +use std::str::FromStr; +use types::id::Id; + +fn message(err: &trc::Error) -> String { + err.value_as_str(trc::Key::Reason) + .or_else(|| err.value_as_str(trc::Key::Details)) + .map(str::to_string) + .unwrap_or_else(|| err.to_string()) +} + +fn kind(directory: &Directory) -> &'static str { + match directory { + Directory::Ldap(_) => "ldap", + Directory::Sql(_) => "sql", + Directory::OpenId(_) => "oidc", + Directory::Unavailable(d) => match d.directory_type() { + registry::schema::enums::DirectoryType::Ldap => "ldap", + registry::schema::enums::DirectoryType::Sql => "sql", + registry::schema::enums::DirectoryType::Oidc => "oidc", + }, + } +} + +pub fn assert_allowed(access_token: &AccessToken) -> trc::Result<()> { + if access_token.tenant_id().is_some() { + return Err(trc::JmapEvent::Forbidden + .into_err() + .details("Directory tests are for server-level administrators.")); + } + access_token.enforce_permission(Permission::SysDirectoryUpdate) +} + +fn bad(details: &'static str) -> trc::Error { + trc::ResourceEvent::BadParameters.into_err().details(details) +} + +pub async fn test(server: &Server, body: &Value) -> trc::Result { + let directory_id = body + .get("directoryId") + .and_then(Value::as_str) + .and_then(|id| Id::from_str(id).ok()) + .ok_or_else(|| bad("Expected {\"directoryId\": …, \"address\": …}"))?; + let address = body + .get("address") + .and_then(Value::as_str) + .map(|a| a.trim().to_lowercase()) + .filter(|a| !a.is_empty()) + .ok_or_else(|| bad("Expected an address to look up"))?; + let password = body + .get("password") + .and_then(Value::as_str) + .filter(|p| !p.is_empty()); + + let Some(directory) = server + .core + .storage + .directories + .get(&(directory_id.id() as u32)) + .cloned() + else { + return Ok(json!({ + "opened": false, + "error": "The server hasn't loaded this directory. Save it, and try again in a few seconds.", + })); + }; + + let mut out = json!({ "kind": kind(&directory) }); + if let Directory::Unavailable(d) = directory.as_ref() { + out["opened"] = json!(false); + out["error"] = json!(message(&d.error())); + return Ok(out); + } + out["opened"] = json!(true); + + if let Some(discovery) = directory.oidc_discovery_document() { + out["oidc"] = json!({ + "issuer": discovery.document.issuer, + "jwksUri": discovery.document.jwks_uri, + }); + } + + // What mail for this address would find. + if directory.can_lookup_recipients() { + out["lookup"] = match directory.recipient(&address).await { + Ok(Recipient::Account(a)) => json!({ + "found": "account", + "email": a.email, + "aliases": a.email_aliases, + "groups": a.groups.unwrap_or_default(), + "description": a.description, + }), + Ok(Recipient::Group(g)) => json!({ + "found": "group", + "email": g.email, + "aliases": g.email_aliases, + "description": g.description, + }), + Ok(Recipient::Invalid) => json!({ "found": "none" }), + Err(err) => json!({ "error": message(&err) }), + }; + } + + // Whether this person could sign in. OIDC takes tokens, not passwords + // (DIR-29), so there's nothing to try there. + if let Some(password) = password + && !matches!(directory.as_ref(), Directory::OpenId(_)) + { + let credentials = Credentials::Basic { + username: address.clone(), + secret: password.to_string(), + mfa_token: None, + }; + out["signIn"] = match directory.authenticate(&credentials).await { + Ok(a) => json!({ + "ok": true, + "email": a.email, + "groups": a.groups.unwrap_or_default(), + "description": a.description, + }), + Err(err) if matches!(err.as_ref(), trc::EventType::Auth(trc::AuthEvent::Failed)) => { + json!({ "ok": false, "wrongPassword": true }) + } + Err(err) => json!({ "ok": false, "error": message(&err) }), + }; + } + + Ok(out) +} diff --git a/crates/jmap/src/inbuxa/mod.rs b/crates/jmap/src/inbuxa/mod.rs index bee7753..44fafb5 100644 --- a/crates/jmap/src/inbuxa/mod.rs +++ b/crates/jmap/src/inbuxa/mod.rs @@ -17,6 +17,7 @@ pub mod audit_log; pub mod ai_limits; pub mod log_settings; pub mod data_inventory; +pub mod directory_test; pub mod explanation; pub mod protocol_policy; pub mod tenant_protocol_policy;