Try a directory before anything signs in through it #95

Merged
jcoffey-dev merged 1 commits from feature/directory-test into main 2026-09-28 21:51:56 +00:00
3 changed files with 168 additions and 0 deletions
+11
View File
@@ -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::<serde_json::Value>(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?;
+156
View File
@@ -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": "[email protected]", "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<Value> {
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)
}
+1
View File
@@ -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;