From 8d49fb924874e5fc666607042735131873c76562 Mon Sep 17 00:00:00 2001 From: Cody Schaefer Date: Fri, 21 Aug 2026 16:00:50 -0500 Subject: [PATCH] T10.9 fix - the CLI's auth failure said nothing useful "Authentication failed." and no more. I had carried the generic-message reasoning across from /api/auth/login, where one indistinguishable failure is correct because an unauthenticated caller must not be able to enumerate accounts. That reasoning does not transfer to a local CLI: the operator IS the account holder, there is nobody to leak to, and withholding AD's own sub-code just makes the failure undiagnosable. It cost a round trip to find that out. Now prints the reason, the AD error-49 sub-code, the exact bind string attempted, the server, and the password length - enough to tell apart a wrong password (52e), a locked account (775), an expired one (532), and the case that looks like a bad password but is not: 525, no such user, which means the BIND NAME is wrong rather than the credential. That last one matters here. The bind is @LDAP_DOMAIN, and LDAP_DOMAIN defaults to the AD DNS name (prime.local). If an estate's UPN suffix is the mail domain instead, that string is not a valid bind name and AD answers 525 - which reads as "bad password" to anyone not looking at sub-codes. The hint says so, and says to set LDAP_DOMAIN to the UPN suffix. The password is never echoed or logged; only its length, which distinguishes "getpass read nothing" from "getpass read the wrong thing". /api/auth/login is untouched. Its generic 401 is still correct. Co-Authored-By: Claude Opus 5 (1M context) --- server/manage_users.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/server/manage_users.py b/server/manage_users.py index ed18acf..cabb63c 100644 --- a/server/manage_users.py +++ b/server/manage_users.py @@ -84,9 +84,27 @@ def authenticate_operator() -> str: # not be able to lock an operator out of the tool that fixes the login group. result = ldap_auth.verify(who, pw, required_group="") if not result.ok: - if result.is_config_problem: - sys.exit(f"Could not reach the domain ({result.reason}): {result.detail}") - sys.exit("Authentication failed.") + # SAY WHY. The /api/auth/login endpoint deliberately returns one generic + # message so an unauthenticated caller cannot enumerate accounts; that + # reasoning does NOT transfer here. This is a local tool, the operator is + # the account holder, and there is nobody to leak to — so withholding the + # AD sub-code only makes a failure undiagnosable. An earlier version of + # this function printed "Authentication failed." and nothing else. + print(f"Authentication failed: {result.reason} — {result.detail}", file=sys.stderr) + print(f" bind attempted as : {ldap_auth.normalize_username(who)}@{ldap_auth.DOMAIN}", + file=sys.stderr) + print(f" server : ldaps://{ldap_auth.HOST}:{ldap_auth.PORT}", file=sys.stderr) + print(f" password length : {len(pw)} characters", file=sys.stderr) + if result.reason == ldap_auth.BAD_CREDENTIALS: + print(" The sub-code above is AD's own reason: 52e = wrong password, " + "775 = account locked out, 532 = password expired, " + "533 = account disabled, 525 = no such user.", file=sys.stderr) + print(" A 525 with a password you know is correct means the BIND NAME is " + "wrong, not the password. This binds as @LDAP_DOMAIN, " + "which only works where that matches your real UPN suffix — set " + "LDAP_DOMAIN to the UPN suffix if yours differs from the AD DNS name.", + file=sys.stderr) + sys.exit(1) print(f"Authenticated as {result.sam}.") return result.sam