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 <sAMAccountName>@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) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 16:00:50 -05:00
parent 495d87dd72
commit 8d49fb9248

View File

@@ -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 <sAMAccountName>@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