Skip to content

Commit d87a57a

Browse files
authored
fix(auth_identity): drop local qualifier before a qualified name (#767)
`Username::parse(".\\AzureAD\\user@example.com")` and `AuthIdentityBuffers { user: "AzureAD\\user@example.com", domain: "." }` failed with `MixedFormat`, because the first `\` made `.` the NetBIOS domain and left a `\` inside the account name. A local machine qualifier (`.\`) in front of an already-qualified name is now dropped, so both forms read as `AzureAD\user@example.com`. `Username::new(account, Some("."))` with a qualified account name is parsed the same way, which also covers the CredSSP/FFI path where the domain arrives as `.` in a separate field. Does not change: - `.\user` keeps `.` as its NetBIOS domain. - Every input that parsed before parses exactly as before; only inputs that were `Err(MixedFormat)` are now accepted. - Not verified against Windows: that `.\X\Y` and `X\Y` are treated as the same identity there. `X\Y` is the form Microsoft documents for Entra-joined RDP.
1 parent 21359c1 commit d87a57a

1 file changed

Lines changed: 56 additions & 2 deletions

File tree

‎src/auth_identity.rs‎

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,11 +217,15 @@ impl Username {
217217
/// Attempts to guess the right name format for the account name/domain combo
218218
///
219219
/// If no netbios domain name is provided, or if it is an empty string, the username will
220-
/// be parsed as either a user principal name or a down-level logon name.
220+
/// be parsed as either a user principal name or a down-level logon name. The same applies when
221+
/// the netbios domain name is the local machine qualifier (`.`) and the account name is already
222+
/// qualified, as in `AzureAD\user@example.com` (#766).
221223
///
222224
/// It falls back to a down-level logon name when the format can’t be guessed.
223225
pub fn new(account_name: &str, netbios_domain_name: Option<&str>) -> Result<Self, UsernameError> {
224226
match netbios_domain_name {
227+
// NOTE: `.` is the local machine qualifier in front of an already-qualified account name.
228+
Some(".") if account_name.contains('\\') => Self::parse(account_name),
225229
Some(netbios_domain_name) if !netbios_domain_name.is_empty() => {
226230
Self::new_down_level_logon_name(account_name, netbios_domain_name)
227231
}
@@ -238,7 +242,16 @@ impl Username {
238242
/// everything after it is the account name, so `MicrosoftAccount\user@example.com` parses as
239243
/// NetBIOS domain `MicrosoftAccount` with account name `user@example.com` — matching how
240244
/// Windows itself reads that qualification.
245+
///
246+
/// A local machine qualifier (`.\`) in front of an already-qualified name is dropped, so
247+
/// `.\AzureAD\user@example.com` parses the same as `AzureAD\user@example.com` (#766). A `.\`
248+
/// in front of an unqualified account name is kept as its NetBIOS domain.
241249
pub fn parse(value: &str) -> Result<Self, UsernameError> {
250+
let value = match value.strip_prefix(".\\") {
251+
Some(qualified_name) if qualified_name.contains('\\') => qualified_name,
252+
_ => value,
253+
};
254+
242255
match (value.split_once('\\'), value.rsplit_once('@')) {
243256
(None, None) => Ok(Self {
244257
value: value.to_owned(),
@@ -807,7 +820,11 @@ mod tests {
807820
let res = Username::parse(&value);
808821
prop_assume!(res.is_ok());
809822
let initial_username = res.unwrap();
810-
assert_eq!(initial_username.inner(), value);
823+
824+
// A local machine qualifier in front of a qualified name is dropped (#766).
825+
assert!(
826+
initial_username.inner() == value || value.strip_prefix(".\\") == Some(initial_username.inner())
827+
);
811828

812829
// The "domain-ish" component, whatever its format-specific meaning is.
813830
let domain = match initial_username.parts() {
@@ -957,6 +974,43 @@ mod tests {
957974
assert_eq!(bare.account_name(), "me");
958975
}
959976

977+
/// #766: mstsc may prefix an already-qualified name with the local machine qualifier, as in
978+
/// `.\AzureAD\user@example.com`. The `.\` is dropped, and the name is read as the qualified
979+
/// name it precedes.
980+
#[test]
981+
fn local_qualifier_before_a_qualified_name_is_dropped() {
982+
let expected = Username::parse("AzureAD\\user@example.com").expect("qualified form");
983+
984+
let local_qualified = Username::parse(".\\AzureAD\\user@example.com").expect("local-qualified form");
985+
assert_eq!(local_qualified, expected);
986+
assert_eq!(
987+
local_qualified.parts(),
988+
UsernameParts::DownLevelLogonName(DownLevelLogonNameParts {
989+
account_name: "user@example.com",
990+
netbios_domain: Some("AzureAD"),
991+
})
992+
);
993+
check_round_trip_property(&local_qualified);
994+
995+
// The same identity supplied as separate username + domain fields.
996+
let split = Username::new("AzureAD\\user@example.com", Some(".")).expect("split form");
997+
assert_eq!(split, expected);
998+
999+
let buffers = AuthIdentityBuffers::from_utf8("AzureAD\\user@example.com", ".", "");
1000+
let identity = AuthIdentity::try_from(&buffers).expect("buffers form");
1001+
assert_eq!(identity.username, expected);
1002+
1003+
// A local account name keeps its `.` NetBIOS domain.
1004+
let local_account = Username::parse(".\\user").expect("local account");
1005+
assert_eq!(
1006+
local_account.parts(),
1007+
UsernameParts::DownLevelLogonName(DownLevelLogonNameParts {
1008+
account_name: "user",
1009+
netbios_domain: Some("."),
1010+
})
1011+
);
1012+
}
1013+
9601014
#[test]
9611015
fn eq_ignore_ascii_case_matches_within_format() {
9621016
// UPNs that differ only by ASCII case are equal.

0 commit comments

Comments
 (0)