Skip to content

Commit 588da38

Browse files
committed
Identifier Normalization & Improved Exists Logic
1 parent 026469d commit 588da38

File tree

17 files changed

+433
-125
lines changed

17 files changed

+433
-125
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
namespace CodeBeam.UltimateAuth.Core.Contracts;
2+
3+
public enum CaseHandling
4+
{
5+
Preserve,
6+
ToLower,
7+
ToUpper
8+
}

src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol
208208
services.TryAddScoped<IRefreshResponsePolicy, RefreshResponsePolicy>();
209209
services.TryAddSingleton<IAuthStore, InMemoryAuthStore>();
210210
services.TryAddScoped<ICurrentUser, HttpContextCurrentUser>();
211+
services.TryAddScoped<IIdentifierNormalizer, IdentifierNormalizer>();
211212

212213
services.TryAddScoped<IHubCapabilities, HubCapabilities>();
213214

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
using CodeBeam.UltimateAuth.Users.Contracts;
2+
3+
namespace CodeBeam.UltimateAuth.Server.Infrastructure;
4+
5+
public interface IIdentifierNormalizer
6+
{
7+
NormalizedIdentifier Normalize(UserIdentifierType type, string value);
8+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
using CodeBeam.UltimateAuth.Core.Contracts;
2+
using CodeBeam.UltimateAuth.Server.Options;
3+
using CodeBeam.UltimateAuth.Users.Contracts;
4+
using Microsoft.Extensions.Options;
5+
using System.Globalization;
6+
using System.Text;
7+
8+
namespace CodeBeam.UltimateAuth.Server.Infrastructure;
9+
10+
public sealed class IdentifierNormalizer : IIdentifierNormalizer
11+
{
12+
private readonly UAuthIdentifierNormalizationOptions _options;
13+
14+
public IdentifierNormalizer(IOptions<UAuthServerOptions> options)
15+
{
16+
_options = options.Value.LoginIdentifiers.Normalization;
17+
}
18+
19+
public NormalizedIdentifier Normalize(UserIdentifierType type, string value)
20+
{
21+
if (string.IsNullOrWhiteSpace(value))
22+
return new(value, string.Empty, false, "identifier_empty");
23+
24+
var raw = value;
25+
var normalized = BasicNormalize(value);
26+
27+
return type switch
28+
{
29+
UserIdentifierType.Email => NormalizeEmail(raw, normalized),
30+
UserIdentifierType.Phone => NormalizePhone(raw, normalized),
31+
UserIdentifierType.Username => NormalizeUsername(raw, normalized),
32+
_ => NormalizeCustom(raw, normalized)
33+
};
34+
}
35+
36+
private static string BasicNormalize(string value)
37+
{
38+
var form = value.Normalize(NormalizationForm.FormKC).Trim();
39+
40+
var sb = new StringBuilder(form.Length);
41+
foreach (var ch in form)
42+
{
43+
if (char.IsControl(ch))
44+
continue;
45+
46+
if (ch is '\u200B' or '\u200C' or '\u200D' or '\uFEFF')
47+
continue;
48+
49+
sb.Append(ch);
50+
}
51+
52+
return sb.ToString();
53+
}
54+
55+
private NormalizedIdentifier NormalizeUsername(string raw, string value)
56+
{
57+
if (value.Length < 3 || value.Length > 256)
58+
return new(raw, value, false, "username_invalid_length");
59+
60+
value = ApplyCasePolicy(value, _options.UsernameCase);
61+
62+
return new(raw, value, true, null);
63+
}
64+
65+
private NormalizedIdentifier NormalizeEmail(string raw, string value)
66+
{
67+
var atIndex = value.IndexOf('@');
68+
if (atIndex <= 0 || atIndex != value.LastIndexOf('@'))
69+
return new(raw, value, false, "email_invalid_format");
70+
71+
var local = value[..atIndex];
72+
var domain = value[(atIndex + 1)..];
73+
74+
if (string.IsNullOrWhiteSpace(domain) || !domain.Contains('.'))
75+
return new(raw, value, false, "email_invalid_domain");
76+
77+
try
78+
{
79+
var idn = new IdnMapping();
80+
domain = idn.GetAscii(domain);
81+
}
82+
catch
83+
{
84+
return new(raw, value, false, "email_invalid_domain");
85+
}
86+
87+
var normalized = $"{local}@{domain}";
88+
normalized = ApplyCasePolicy(normalized, _options.EmailCase);
89+
90+
return new(raw, normalized, true, null);
91+
}
92+
93+
private NormalizedIdentifier NormalizePhone(string raw, string value)
94+
{
95+
var sb = new StringBuilder();
96+
97+
foreach (var ch in value)
98+
{
99+
if (char.IsDigit(ch))
100+
sb.Append(ch);
101+
else if (ch == '+' && sb.Length == 0)
102+
sb.Append(ch);
103+
}
104+
105+
var digits = sb.ToString();
106+
107+
if (digits.Length < 7)
108+
return new(raw, digits, false, "phone_invalid_length");
109+
110+
return new(raw, digits, true, null);
111+
}
112+
113+
private NormalizedIdentifier NormalizeCustom(string raw, string value)
114+
{
115+
value = ApplyCasePolicy(value, _options.CustomCase);
116+
117+
if (value.Length == 0)
118+
return new(raw, value, false, "identifier_invalid");
119+
120+
return new(raw, value, true, null);
121+
}
122+
123+
private static string ApplyCasePolicy(string value, CaseHandling policy)
124+
{
125+
return policy switch
126+
{
127+
CaseHandling.ToLower => value.ToLowerInvariant(),
128+
CaseHandling.ToUpper => value.ToUpperInvariant(),
129+
_ => value
130+
};
131+
}
132+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
namespace CodeBeam.UltimateAuth.Server.Infrastructure;
2+
3+
public readonly record struct NormalizedIdentifier(
4+
string Raw,
5+
string Normalized,
6+
bool IsValid,
7+
string? ErrorCode);

src/CodeBeam.UltimateAuth.Server/Options/UAuthLoginIdentifierOptions.cs

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using CodeBeam.UltimateAuth.Users.Contracts;
1+
using CodeBeam.UltimateAuth.Core.Contracts;
2+
using CodeBeam.UltimateAuth.Users.Contracts;
23

34
namespace CodeBeam.UltimateAuth.Server.Options;
45
public sealed class UAuthLoginIdentifierOptions
@@ -17,6 +18,8 @@ public sealed class UAuthLoginIdentifierOptions
1718
public bool EnableCustomResolvers { get; set; } = true;
1819
public bool CustomResolversFirst { get; set; } = true;
1920

21+
public UAuthIdentifierNormalizationOptions Normalization { get; set; } = new();
22+
2023
public bool EnforceGlobalUniquenessForAllIdentifiers { get; set; } = false;
2124

2225
internal UAuthLoginIdentifierOptions Clone() => new()
@@ -26,6 +29,21 @@ public sealed class UAuthLoginIdentifierOptions
2629
RequireVerificationForPhone = RequireVerificationForPhone,
2730
EnableCustomResolvers = EnableCustomResolvers,
2831
CustomResolversFirst = CustomResolversFirst,
29-
EnforceGlobalUniquenessForAllIdentifiers = EnforceGlobalUniquenessForAllIdentifiers
32+
EnforceGlobalUniquenessForAllIdentifiers = EnforceGlobalUniquenessForAllIdentifiers,
33+
Normalization = Normalization.Clone()
34+
};
35+
}
36+
37+
public sealed class UAuthIdentifierNormalizationOptions
38+
{
39+
public CaseHandling UsernameCase { get; set; } = CaseHandling.Preserve;
40+
public CaseHandling EmailCase { get; set; } = CaseHandling.ToLower;
41+
public CaseHandling CustomCase { get; set; } = CaseHandling.Preserve;
42+
43+
internal UAuthIdentifierNormalizationOptions Clone() => new()
44+
{
45+
UsernameCase = UsernameCase,
46+
EmailCase = EmailCase,
47+
CustomCase = CustomCase
3048
};
3149
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using CodeBeam.UltimateAuth.Core.Domain;
2+
using CodeBeam.UltimateAuth.Core.MultiTenancy;
3+
4+
namespace CodeBeam.UltimateAuth.Users.Contracts;
5+
6+
public sealed record IdentifierExistenceQuery(
7+
TenantKey Tenant,
8+
UserIdentifierType Type,
9+
string NormalizedValue,
10+
IdentifierExistenceScope Scope,
11+
UserKey? UserKey = null,
12+
Guid? ExcludeIdentifierId = null
13+
);
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
namespace CodeBeam.UltimateAuth.Users.Contracts;
2+
3+
public enum IdentifierExistenceScope
4+
{
5+
/// <summary>
6+
/// Checks only within the same user.
7+
/// </summary>
8+
WithinUser,
9+
10+
/// <summary>
11+
/// Checks within tenant but only primary identifiers.
12+
/// </summary>
13+
TenantPrimaryOnly,
14+
15+
/// <summary>
16+
/// Checks within tenant regardless of primary flag.
17+
/// </summary>
18+
TenantAny
19+
}

src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierDto.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ public sealed record UserIdentifierDto
55
public Guid Id { get; set; }
66
public required UserIdentifierType Type { get; set; }
77
public required string Value { get; set; }
8+
public string NormalizedValue { get; set; } = default!;
89
public bool IsPrimary { get; set; }
910
public bool IsVerified { get; set; }
1011
public DateTimeOffset CreatedAt { get; init; }
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
using CodeBeam.UltimateAuth.Core.Domain;
2+
3+
namespace CodeBeam.UltimateAuth.Users.Contracts;
4+
5+
public sealed record IdentifierExistenceResult(
6+
bool Exists,
7+
UserKey? OwnerUserKey = null,
8+
Guid? OwnerIdentifierId = null,
9+
bool OwnerIsPrimary = false
10+
);

0 commit comments

Comments
 (0)