diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/LdapUserName.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/LdapUserName.cs
new file mode 100644
index 0000000..8c500a6
--- /dev/null
+++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/LdapUserName.cs
@@ -0,0 +1,57 @@
+namespace ZB.MOM.WW.Auth.Ldap.Internal;
+
+///
+/// Reduces a typed login to the bare account name used by :
+/// one spelling flows into the bind template, the search filter and the returned username, so audit
+/// rows never carry DOMAIN\jdoe for one sign-in and jdoe for the next.
+///
+internal static class LdapUserName
+{
+ ///
+ /// Trims and, when , strips one
+ /// leading DOMAIN\ or one trailing @suffix. Returns (with
+ /// empty) for blank input, for both forms at once, for a repeated
+ /// separator, for an empty side, or for any qualified form when
+ /// is .
+ ///
+ public static bool TryNormalize(string? input, bool acceptDomainQualified, out string name)
+ {
+ name = "";
+ if (string.IsNullOrWhiteSpace(input))
+ return false;
+
+ var s = input.Trim();
+ var slash = s.IndexOf('\\');
+ var at = s.IndexOf('@');
+
+ if (slash >= 0 || at >= 0)
+ {
+ if (!acceptDomainQualified || (slash >= 0 && at >= 0))
+ return false;
+
+ if (slash >= 0)
+ {
+ if (slash == 0 || s.IndexOf('\\', slash + 1) >= 0)
+ return false;
+ s = s[(slash + 1)..];
+ }
+ else
+ {
+ // Unlike the slash branch (whose extracted suffix is checked for emptiness by the
+ // final length check below), the extracted piece here is the *prefix* — so an empty
+ // domain part (a trailing "@", e.g. "jdoe@") must be rejected explicitly.
+ if (at == s.Length - 1 || s.IndexOf('@', at + 1) >= 0)
+ return false;
+ s = s[..at];
+ }
+
+ s = s.Trim();
+ }
+
+ if (s.Length == 0)
+ return false;
+
+ name = s;
+ return true;
+ }
+}
diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapUserNameTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapUserNameTests.cs
new file mode 100644
index 0000000..8d0da1b
--- /dev/null
+++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapUserNameTests.cs
@@ -0,0 +1,47 @@
+using ZB.MOM.WW.Auth.Ldap.Internal;
+
+namespace ZB.MOM.WW.Auth.Ldap.Tests;
+
+public class LdapUserNameTests
+{
+ [Theory]
+ [InlineData("jdoe")]
+ [InlineData(" jdoe ")]
+ [InlineData(@"NAM\jdoe")]
+ [InlineData("jdoe@corp.test")]
+ public void Accepting_ReducesToBareName(string input)
+ {
+ Assert.True(LdapUserName.TryNormalize(input, acceptDomainQualified: true, out var name));
+ Assert.Equal("jdoe", name);
+ }
+
+ [Theory]
+ [InlineData(@"NAM\jdoe")]
+ [InlineData("jdoe@corp.test")]
+ public void Strict_RefusesQualifiedNames(string input) =>
+ Assert.False(LdapUserName.TryNormalize(input, acceptDomainQualified: false, out _));
+
+ [Fact]
+ public void Strict_AcceptsBareName()
+ {
+ Assert.True(LdapUserName.TryNormalize(" jdoe ", acceptDomainQualified: false, out var name));
+ Assert.Equal("jdoe", name);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("@x")]
+ [InlineData(@"\jdoe")]
+ [InlineData("jdoe@")]
+ [InlineData(@"NAM\")]
+ [InlineData(@"a\b\c")]
+ [InlineData("a@b@c")]
+ [InlineData(@"NAM\jdoe@corp.test")]
+ public void Unusable_IsRefused(string? input)
+ {
+ Assert.False(LdapUserName.TryNormalize(input, acceptDomainQualified: true, out var name));
+ Assert.Equal("", name);
+ }
+}