diff --git a/README.md b/README.md
index 0ce9300..6cb10a1 100644
--- a/README.md
+++ b/README.md
@@ -24,9 +24,12 @@ Directory synchronization, deployment scripts, and tests.
No derived password is created. Passwords are not written to a database, file,
event log, application log, command line, or response.
-For administrative accounts, profile enrichment targets the read-only incident
-overview and reads only the employee number, name, account type/status, email,
-job title, and department from their stable element IDs. Incident, calendar,
+For administrative accounts, profile enrichment first verifies the employee
+number against the incident overview, then reads the structured given names and
+surnames from `datos/personales.aspx` and the postal address from
+`datos/ubicacion.aspx` in the same authenticated session. Account type/status,
+email, job title, and department remain sourced from the incident overview.
+Birth date, identifiers, telephone, emergency-contact, incident, calendar,
photo, and manager fields are ignored. Student enrichment targets the read-only
student information page and reads only the matching student number, structured
name, email, career, and postal address. The career becomes an AD title in the
diff --git a/docs/architecture.md b/docs/architecture.md
index af72372..ff61d79 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -25,8 +25,12 @@ returns the NTLM challenge without waiting for the slow application pages. A
`401` or `403` rejects the credential; an allowed `2xx` or `3xx` proves that IIS
accepted it. The broker then makes a separately bounded, best-effort GET to the
administrative incident overview for `AD`, the student information page for
-`AL`, or the portal menu for `DO`. A profile timeout does not invalidate an
-already authenticated credential. NTLM may still require its normal
+`AL`, or the portal menu for `DO`. After the incident page confirms an `AD`
+employee number, two additional GETs in the same in-memory session read the
+structured name from `datos/personales.aspx` and the address from
+`datos/ubicacion.aspx`. A supplemental failure preserves fields already
+collected, and a profile timeout does not invalidate an already authenticated
+credential. NTLM may still require its normal
challenge/response round trips on the connection. Transient portal cookies are
kept only in an in-memory per-request container and are never persisted or
returned to the client.
@@ -54,8 +58,9 @@ When the authenticated HTML exposes recognized stable IDs, the broker also
updates the applicable `displayName`, `givenName`, `sn`, `mail`, `title`,
`department`, `employeeType`, `employeeID`, `streetAddress`, `l`, `st`, and
`postalCode` attributes. Administrative and student numbers must match the six
-numeric digits of the requested identity before any scraped metadata is
-trusted. Student faculty/department is deliberately left unset because the
+numeric digits of the requested identity before any role-specific metadata is
+trusted. Administrative personal and location pages are accepted only after
+that incident-page match. Student faculty/department is deliberately left unset because the
verified page does not expose it. Missing metadata does not clear existing AD
values and never changes the password outcome.
diff --git a/docs/broker-operations.md b/docs/broker-operations.md
index 84178f7..76c4899 100644
--- a/docs/broker-operations.md
+++ b/docs/broker-operations.md
@@ -46,13 +46,14 @@ Eso es comportamiento esperado, no una caída del servicio.
## Timeouts y recuperación
-- El Credential Provider espera hasta **35 segundos** por el broker.
+- El Credential Provider espera hasta **90 segundos** por el broker.
- El broker permite hasta **20 segundos** para el desafío NTLM ligero de
- `/psulsa/` y hasta **10 segundos totales** adicionales para enriquecer el
- perfil. La consulta de perfil es best effort: si la página pesada queda
- congelada después de que NTLM aceptó la contraseña, el usuario se sincroniza
- sin metadatos y puede iniciar sesión. El máximo combinado queda por debajo de
- los 35 segundos del cliente.
+ `/psulsa/`. El enriquecimiento usa el límite total independiente
+ `ProfileTimeoutSeconds` —**90 segundos** en la configuración del laboratorio—
+ y conserva los campos que alcance a obtener si una página administrativa se
+ retrasa o falla. El Credential Provider mantiene su propio límite de **90
+ segundos**: si SGU excede ese presupuesto, Windows continúa por el fallback
+ normal de AD o credenciales de dominio en caché.
- El instalador configura recuperación del servicio con reinicios a los 5, 15
y 60 segundos y reinicia el contador de fallos después de 24 horas.
- Si el broker o SGU no está disponible, el Credential Provider entrega la
diff --git a/docs/security.md b/docs/security.md
index 3b93784..e96c323 100644
--- a/docs/security.md
+++ b/docs/security.md
@@ -30,8 +30,14 @@
## Profile minimization
-- Administrative enrichment reads only employee number, display name,
- employee type/status, email, job title, and department from known element IDs.
+- Administrative enrichment first verifies the employee number and reads
+ employee type/status, email, job title, and department from the incident
+ overview. Only after that match, it reads given names and paternal/maternal
+ surnames from the personal page plus street, exterior/interior number,
+ neighborhood, locality, state, and postal code from the location page.
+- Administrative birth date, RFC, CURP, sex, blood type, marital status,
+ nationality, telephone, email lists, housing type, and emergency-contact
+ fields are ignored.
- Student enrichment reads only the matching student number, given names,
paternal/maternal surnames, email, career, street, neighborhood,
city/municipality, state, and postal code from known element IDs.
@@ -40,11 +46,13 @@
- Incident details, calendars, photographs, manager names, and manager positions
are deliberately ignored.
- The employee or student number must match the authenticated `AD` or `AL` key
- before metadata is synchronized.
+ before role-specific metadata is synchronized. The two supplemental
+ administrative pages are never requested unless the incident page supplied
+ the matching employee number.
- If SGU changes its HTML, authentication and exact-password synchronization
continue without enrichment; existing AD metadata is not erased.
- Slow profile pages cannot change an accepted credential into a rejection. The
- lightweight NTLM root is authoritative; enrichment has its own shorter total
+ lightweight NTLM root is authoritative; enrichment has its own independent total
timeout.
Lab self-signed certificates are appropriate only for the isolated VM network.
diff --git a/scripts/Deploy-AuthBroker.ps1 b/scripts/Deploy-AuthBroker.ps1
index c37822c..a7cf44a 100644
--- a/scripts/Deploy-AuthBroker.ps1
+++ b/scripts/Deploy-AuthBroker.ps1
@@ -17,6 +17,10 @@ param(
[ValidatePattern('^/')]
[string]$AdministrativeProfilePath = '/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx',
[ValidatePattern('^/')]
+ [string]$AdministrativePersonalProfilePath = '/psulsa/gadmon/capitalhumano/datos/personales.aspx',
+ [ValidatePattern('^/')]
+ [string]$AdministrativeLocationProfilePath = '/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx',
+ [ValidatePattern('^/')]
[string]$StudentProfilePath = '/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx',
[ValidatePattern('^/')]
[string]$MenuProfilePath = '/psulsa/menu.aspx',
@@ -32,7 +36,7 @@ param(
[ValidateRange(10, 60)]
[int]$NtlmTimeoutSeconds = 20,
[ValidateRange(2, 90)]
- [int]$ProfileTimeoutSeconds = 60,
+ [int]$ProfileTimeoutSeconds = 90,
[switch]$CreateMissingOus,
[switch]$DisableCertificateRevocationCheckForLab
)
@@ -149,6 +153,8 @@ $productionSettings = @{
MaxRedirects = 5
AuthenticationPath = $AuthenticationPath
AdministrativeProfilePath = $AdministrativeProfilePath
+ AdministrativePersonalProfilePath = $AdministrativePersonalProfilePath
+ AdministrativeLocationProfilePath = $AdministrativeLocationProfilePath
StudentProfilePath = $StudentProfilePath
MenuProfilePath = $MenuProfilePath
MaxProfileBytes = $MaxProfileBytes
diff --git a/src/SGU.AuthBroker.Core/Profiles/InstitutionalProfile.cs b/src/SGU.AuthBroker.Core/Profiles/InstitutionalProfile.cs
index 630b128..90ce581 100644
--- a/src/SGU.AuthBroker.Core/Profiles/InstitutionalProfile.cs
+++ b/src/SGU.AuthBroker.Core/Profiles/InstitutionalProfile.cs
@@ -27,4 +27,23 @@ public sealed record InstitutionalProfile(
City is not null ||
State is not null ||
PostalCode is not null;
+
+ public InstitutionalProfile Overlay(InstitutionalProfile? values) =>
+ values is null
+ ? this
+ : this with
+ {
+ EmployeeNumber = values.EmployeeNumber ?? EmployeeNumber,
+ DisplayName = values.DisplayName ?? DisplayName,
+ GivenName = values.GivenName ?? GivenName,
+ Surname = values.Surname ?? Surname,
+ Email = values.Email ?? Email,
+ EmployeeType = values.EmployeeType ?? EmployeeType,
+ JobTitle = values.JobTitle ?? JobTitle,
+ Department = values.Department ?? Department,
+ StreetAddress = values.StreetAddress ?? StreetAddress,
+ City = values.City ?? City,
+ State = values.State ?? State,
+ PostalCode = values.PostalCode ?? PostalCode
+ };
}
diff --git a/src/SGU.AuthBroker.Core/Profiles/SguProfileParser.cs b/src/SGU.AuthBroker.Core/Profiles/SguProfileParser.cs
index 03bc33f..fe2c22a 100644
--- a/src/SGU.AuthBroker.Core/Profiles/SguProfileParser.cs
+++ b/src/SGU.AuthBroker.Core/Profiles/SguProfileParser.cs
@@ -11,6 +11,16 @@ public static class SguProfileParser
private const string EmailId = "ctl00_contenedor_decEncabezado_lblCorreo";
private const string JobTitleId = "ctl00_contenedor_decEncabezado_lblPuesto";
private const string DepartmentId = "ctl00_contenedor_decEncabezado_lblDependencia";
+ private const string AdministrativeGivenNameId = "ctl00_contenedor_txtNombre";
+ private const string AdministrativePaternalSurnameId = "ctl00_contenedor_txtApaterno";
+ private const string AdministrativeMaternalSurnameId = "ctl00_contenedor_txtAmaterno";
+ private const string AdministrativeStreetId = "ctl00_contenedor_txtCalle";
+ private const string AdministrativeExteriorNumberId = "ctl00_contenedor_txtNoExt";
+ private const string AdministrativeInteriorNumberId = "ctl00_contenedor_txtNoInt";
+ private const string AdministrativePostalCodeId = "ctl00_contenedor_txtCP";
+ private const string AdministrativeStateId = "ctl00_contenedor_ddlEstado";
+ private const string AdministrativeCityId = "ctl00_contenedor_ddlLocalidad";
+ private const string AdministrativeNeighborhoodId = "ctl00_contenedor_ddlColonia";
private const string MenuNameId = "ctl00_lblNombreUsuario";
private const string StudentNumberId = "ctl00_contenedor_HistorialAlumno1_lblClaveAlumnoHP";
private const string StudentGivenNameId = "ctl00_contenedor_HistorialAlumno1_lblNombreAlumnoHP";
@@ -56,6 +66,51 @@ public static class SguProfileParser
return profile.HasValues ? profile : null;
}
+ public static InstitutionalProfile? ParseAdministrativePersonal(string html)
+ {
+ ArgumentNullException.ThrowIfNull(html);
+
+ string? givenName = NormalizeName(ExtractInputValue(html, AdministrativeGivenNameId), 64);
+ string? paternalSurname = NormalizeSurname(
+ ExtractInputValue(html, AdministrativePaternalSurnameId),
+ 64);
+ string? maternalSurname = NormalizeSurname(
+ ExtractInputValue(html, AdministrativeMaternalSurnameId),
+ 64);
+ string? surname = NormalizeSurname(
+ JoinNonEmpty(" ", paternalSurname, maternalSurname),
+ 64);
+ string? displayName = NormalizeName(JoinNonEmpty(" ", givenName, surname), 256);
+
+ InstitutionalProfile profile = new(
+ DisplayName: displayName,
+ GivenName: givenName,
+ Surname: surname);
+ return profile.HasValues ? profile : null;
+ }
+
+ public static InstitutionalProfile? ParseAdministrativeLocation(string html)
+ {
+ ArgumentNullException.ThrowIfNull(html);
+
+ string? street = NormalizeTitle(ExtractInputValue(html, AdministrativeStreetId), 512);
+ string? exteriorNumber = NormalizeAddressUnit(
+ ExtractInputValue(html, AdministrativeExteriorNumberId));
+ string? interiorNumber = NormalizeAddressUnit(
+ ExtractInputValue(html, AdministrativeInteriorNumberId));
+ string? streetLine = BuildAdministrativeStreetLine(street, exteriorNumber, interiorNumber);
+ string? neighborhood = NormalizeTitle(
+ ExtractSelectedOptionText(html, AdministrativeNeighborhoodId),
+ 256);
+
+ InstitutionalProfile profile = new(
+ StreetAddress: BuildStreetAddress(streetLine, neighborhood, null, null),
+ City: NormalizeTitle(ExtractSelectedOptionText(html, AdministrativeCityId), 128),
+ State: NormalizeTitle(ExtractSelectedOptionText(html, AdministrativeStateId), 128),
+ PostalCode: NormalizePostalCode(ExtractInputValue(html, AdministrativePostalCodeId)));
+ return profile.HasValues ? profile : null;
+ }
+
public static InstitutionalProfile? ParseStudent(string html, string expectedStudentNumber)
{
ArgumentNullException.ThrowIfNull(html);
@@ -162,6 +217,158 @@ public static class SguProfileParser
return null;
}
+ private static string? ExtractInputValue(string html, string id)
+ {
+ string? openingTag = FindOpeningTag(html, "input", id);
+ return openingTag is null
+ ? null
+ : NormalizeText(ExtractAttributeValue(openingTag, "value") ?? string.Empty);
+ }
+
+ private static string? ExtractSelectedOptionText(string html, string id)
+ {
+ string? openingTag = FindOpeningTag(html, "select", id);
+ if (openingTag is null)
+ {
+ return null;
+ }
+
+
+ int openingTagIndex = html.IndexOf(openingTag, StringComparison.OrdinalIgnoreCase);
+ int contentStart = openingTagIndex + openingTag.Length;
+ int contentEnd = html.IndexOf(" nonPlaceholderOptions = [];
+ int searchFrom = 0;
+ while (searchFrom < optionsHtml.Length)
+ {
+ int optionStart = optionsHtml.IndexOf("', idIndex);
+ if (tagStart > precedingTagEnd)
+ {
+ int tagEnd = html.IndexOf('>', idIndex);
+ if (tagEnd >= 0)
+ {
+ return html[tagStart..(tagEnd + 1)];
+ }
+ }
+
+ searchFrom = idIndex + marker.Length;
+ }
+ }
+
+ return null;
+ }
+
+ private static string? ExtractAttributeValue(string openingTag, string attributeName)
+ {
+ foreach (char quote in new[] { '"', '\'' })
+ {
+ string marker = $"{attributeName}={quote}";
+ int valueStart = openingTag.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
+ if (valueStart < 0)
+ {
+ continue;
+ }
+
+ valueStart += marker.Length;
+ int valueEnd = openingTag.IndexOf(quote, valueStart);
+ if (valueEnd >= 0)
+ {
+ return WebUtility.HtmlDecode(openingTag[valueStart..valueEnd]);
+ }
+ }
+
+ return null;
+ }
+
+ private static bool HasAttribute(string openingTag, string attributeName)
+ {
+ int searchFrom = 0;
+ while (searchFrom < openingTag.Length)
+ {
+ int index = openingTag.IndexOf(attributeName, searchFrom, StringComparison.OrdinalIgnoreCase);
+ if (index < 0)
+ {
+ return false;
+ }
+
+ bool validStart = index == 0 ||
+ char.IsWhiteSpace(openingTag[index - 1]) ||
+ openingTag[index - 1] == '<';
+ int after = index + attributeName.Length;
+ bool validEnd = after >= openingTag.Length ||
+ char.IsWhiteSpace(openingTag[after]) ||
+ openingTag[after] is '=' or '>' or '/';
+ if (validStart && validEnd)
+ {
+ return true;
+ }
+
+ searchFrom = after;
+ }
+
+ return false;
+ }
+
private static string? NormalizeText(string htmlFragment)
{
StringBuilder withoutTags = new(htmlFragment.Length);
@@ -285,6 +492,28 @@ public static class SguProfileParser
return Limit(string.Join("\r\n", lines), 1024);
}
+ private static string? BuildAdministrativeStreetLine(
+ string? street,
+ string? exteriorNumber,
+ string? interiorNumber)
+ {
+ string? line = JoinNonEmpty(" ", street, exteriorNumber);
+ if (line is null)
+ {
+ return null;
+ }
+
+ return interiorNumber is null
+ ? line
+ : $"{line}, Int. {interiorNumber}";
+ }
+
+ private static string? NormalizeAddressUnit(string? value)
+ {
+ string? candidate = Limit(value, 32);
+ return candidate is null ? null : SpanishTextNormalizer.ToTitleCase(candidate);
+ }
+
private static void AddDistinct(List values, string? candidate)
{
if (!string.IsNullOrWhiteSpace(candidate) &&
diff --git a/src/SGU.AuthBroker/Options/BrokerOptions.cs b/src/SGU.AuthBroker/Options/BrokerOptions.cs
index ec43b07..735bfe8 100644
--- a/src/SGU.AuthBroker/Options/BrokerOptions.cs
+++ b/src/SGU.AuthBroker/Options/BrokerOptions.cs
@@ -47,6 +47,8 @@ public sealed class BrokerOptions
{
Ntlm.AuthenticationPath,
Ntlm.AdministrativeProfilePath,
+ Ntlm.AdministrativePersonalProfilePath,
+ Ntlm.AdministrativeLocationProfilePath,
Ntlm.StudentProfilePath,
Ntlm.MenuProfilePath
})
@@ -122,7 +124,7 @@ public sealed class NtlmOptions
public int TimeoutSeconds { get; init; } = 20;
- public int ProfileTimeoutSeconds { get; init; } = 60;
+ public int ProfileTimeoutSeconds { get; init; } = 90;
public int MaxRedirects { get; init; } = 5;
@@ -131,6 +133,12 @@ public sealed class NtlmOptions
public string AdministrativeProfilePath { get; init; } =
"/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx";
+ public string AdministrativePersonalProfilePath { get; init; } =
+ "/psulsa/gadmon/capitalhumano/datos/personales.aspx";
+
+ public string AdministrativeLocationProfilePath { get; init; } =
+ "/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx";
+
public string StudentProfilePath { get; init; } =
"/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx";
diff --git a/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs b/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs
index 3ab7b95..967b595 100644
--- a/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs
+++ b/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs
@@ -325,6 +325,21 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
response,
identity,
timeout.Token).ConfigureAwait(false);
+ if (identity.Role == InstitutionalRole.Administrative &&
+ string.Equals(
+ profile?.EmployeeNumber,
+ identity.NumericId,
+ StringComparison.Ordinal))
+ {
+ profile = await TryEnrichAdministrativeProfileAsync(
+ client,
+ profile!,
+ allowedHosts,
+ timeout.Token,
+ cancellationToken,
+ elapsed).ConfigureAwait(false);
+ }
+
if (profile is null)
{
logger.LogWarning(
@@ -379,6 +394,104 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
return null;
}
+ private async Task TryEnrichAdministrativeProfileAsync(
+ HttpClient client,
+ InstitutionalProfile verifiedProfile,
+ HashSet allowedHosts,
+ CancellationToken timeoutToken,
+ CancellationToken requestCancellationToken,
+ Stopwatch elapsed)
+ {
+ InstitutionalProfile profile = verifiedProfile;
+ (string Path, Func Parser)[] pages =
+ [
+ (options.AdministrativePersonalProfilePath, SguProfileParser.ParseAdministrativePersonal),
+ (options.AdministrativeLocationProfilePath, SguProfileParser.ParseAdministrativeLocation)
+ ];
+
+ foreach ((string path, Func parser) in pages)
+ {
+ try
+ {
+ string? html = await TryFetchAdditionalProfilePageAsync(
+ client,
+ GetProfileUri(path),
+ allowedHosts,
+ timeoutToken).ConfigureAwait(false);
+ profile = profile.Overlay(html is null ? null : parser(html));
+ }
+ catch (OperationCanceledException) when (!requestCancellationToken.IsCancellationRequested)
+ {
+ logger.LogWarning(
+ "SGU administrative profile enrichment reached its total timeout after {ElapsedMilliseconds} ms; preserving fields already collected.",
+ elapsed.ElapsedMilliseconds);
+ break;
+ }
+ catch (Exception exception)
+ {
+ logger.LogWarning(
+ exception,
+ "An optional SGU administrative profile page failed after {ElapsedMilliseconds} ms; preserving fields already collected.",
+ elapsed.ElapsedMilliseconds);
+ }
+ }
+
+ return profile;
+ }
+
+ private async Task TryFetchAdditionalProfilePageAsync(
+ HttpClient client,
+ Uri requestedUri,
+ HashSet allowedHosts,
+ CancellationToken cancellationToken)
+ {
+ Uri current = requestedUri;
+ for (int hop = 0; hop <= options.MaxRedirects; hop++)
+ {
+ if (!IsAllowedHttpsUri(current, allowedHosts))
+ {
+ return null;
+ }
+
+ using HttpRequestMessage request = new(HttpMethod.Get, current);
+ using HttpResponseMessage response = await client
+ .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
+ .ConfigureAwait(false);
+ int statusCode = (int)response.StatusCode;
+ if (statusCode is >= 300 and < 400)
+ {
+ Uri? redirect = ResolveAllowedRedirect(current, response, allowedHosts);
+ if (redirect is null)
+ {
+ return null;
+ }
+
+ await DrainResponseAsync(response, cancellationToken).ConfigureAwait(false);
+ current = redirect;
+ continue;
+ }
+
+ if (statusCode is >= 200 and < 300)
+ {
+ return await ReadLimitedStringAsync(
+ response.Content,
+ options.MaxProfileBytes,
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ logger.LogWarning(
+ "Optional SGU profile page {Path} returned HTTP {StatusCode}.",
+ requestedUri.AbsolutePath,
+ statusCode);
+ return null;
+ }
+
+ logger.LogWarning(
+ "Optional SGU profile page {Path} exceeded the redirect limit.",
+ requestedUri.AbsolutePath);
+ return null;
+ }
+
private static void AddCredential(
Uri uri,
CredentialCache credentialCache,
@@ -472,6 +585,9 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
return new Uri(endpoint, path);
}
+ private Uri GetProfileUri(string path) =>
+ new(new Uri(options.Endpoint, UriKind.Absolute), path);
+
private async Task TryReadProfileAsync(
HttpResponseMessage response,
UserIdentity identity,
diff --git a/src/SGU.AuthBroker/appsettings.json b/src/SGU.AuthBroker/appsettings.json
index 5907d36..94d4163 100644
--- a/src/SGU.AuthBroker/appsettings.json
+++ b/src/SGU.AuthBroker/appsettings.json
@@ -30,10 +30,12 @@
"Endpoint": "https://sgu.ulsa.edu.mx/",
"Domain": "",
"TimeoutSeconds": 20,
- "ProfileTimeoutSeconds": 60,
+ "ProfileTimeoutSeconds": 90,
"MaxRedirects": 5,
"AuthenticationPath": "/psulsa/",
"AdministrativeProfilePath": "/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx",
+ "AdministrativePersonalProfilePath": "/psulsa/gadmon/capitalhumano/datos/personales.aspx",
+ "AdministrativeLocationProfilePath": "/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx",
"StudentProfilePath": "/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx",
"MenuProfilePath": "/psulsa/menu.aspx",
"MaxProfileBytes": 524288,
diff --git a/tests/SGU.AuthBroker.Core.Tests/SguProfileParserTests.cs b/tests/SGU.AuthBroker.Core.Tests/SguProfileParserTests.cs
index 0f0b7ea..2c61217 100644
--- a/tests/SGU.AuthBroker.Core.Tests/SguProfileParserTests.cs
+++ b/tests/SGU.AuthBroker.Core.Tests/SguProfileParserTests.cs
@@ -56,6 +56,100 @@ public sealed class SguProfileParserTests
Assert.Null(SguProfileParser.ParseAdministrative(html, "999999"));
}
+ [Fact]
+ public void ParsesStructuredAdministrativeNameWithoutReadingOtherPersonalData()
+ {
+ const string html = """
+
+
+
+
+
+
+
+ """;
+
+ InstitutionalProfile? profile = SguProfileParser.ParseAdministrativePersonal(html);
+
+ Assert.NotNull(profile);
+ Assert.Equal("María del Carmen", profile.GivenName);
+ Assert.Equal("de la Fuente O'Connor", profile.Surname);
+ Assert.Equal("María del Carmen de la Fuente O'Connor", profile.DisplayName);
+ Assert.Null(profile.EmployeeNumber);
+ Assert.Null(profile.Email);
+ }
+
+ [Fact]
+ public void ParsesAdministrativeAddressFromInputsAndSelectedOptions()
+ {
+ const string html = """
+
+
+
+
+
+
+
+
+
+
+
+ """;
+
+ InstitutionalProfile? profile = SguProfileParser.ParseAdministrativeLocation(html);
+
+ Assert.NotNull(profile);
+ Assert.Equal(
+ "Avenida de la Universidad 123, Int. B-4\r\nColonia del Valle",
+ profile.StreetAddress);
+ Assert.Equal("Benito Juárez", profile.City);
+ Assert.Equal("Ciudad de México", profile.State);
+ Assert.Equal("08500", profile.PostalCode);
+ Assert.Null(profile.Email);
+ }
+
+ [Fact]
+ public void AdministrativePagesOverlayTheVerifiedIncidentsProfile()
+ {
+ InstitutionalProfile verified = new(
+ EmployeeNumber: "017045",
+ DisplayName: "Nombre Anterior",
+ Email: "persona@lasalle.mx",
+ JobTitle: "Analista",
+ Department: "Ingeniería");
+ InstitutionalProfile personal = new(
+ DisplayName: "María del Carmen de la Fuente",
+ GivenName: "María del Carmen",
+ Surname: "de la Fuente");
+ InstitutionalProfile location = new(
+ StreetAddress: "Calle Uno 10",
+ City: "Ciudad de México",
+ State: "Ciudad de México",
+ PostalCode: "01000");
+
+ InstitutionalProfile combined = verified.Overlay(personal).Overlay(location);
+
+ Assert.Equal("017045", combined.EmployeeNumber);
+ Assert.Equal("María del Carmen de la Fuente", combined.DisplayName);
+ Assert.Equal("María del Carmen", combined.GivenName);
+ Assert.Equal("de la Fuente", combined.Surname);
+ Assert.Equal("persona@lasalle.mx", combined.Email);
+ Assert.Equal("Analista", combined.JobTitle);
+ Assert.Equal("Ingeniería", combined.Department);
+ Assert.Equal("Calle Uno 10", combined.StreetAddress);
+ Assert.Equal("01000", combined.PostalCode);
+ }
+
[Fact]
public void ParsesTheRequiredStudentIdentityCareerAndAddressFields()
{
diff --git a/tests/SGU.AuthBroker.Tests/NtlmCredentialValidatorTests.cs b/tests/SGU.AuthBroker.Tests/NtlmCredentialValidatorTests.cs
index fc4b2dc..7adc271 100644
--- a/tests/SGU.AuthBroker.Tests/NtlmCredentialValidatorTests.cs
+++ b/tests/SGU.AuthBroker.Tests/NtlmCredentialValidatorTests.cs
@@ -11,6 +11,12 @@ namespace SGU.AuthBroker.Tests;
public sealed class NtlmCredentialValidatorTests
{
+ private static readonly UserIdentity Administrative = new(
+ "AD017045",
+ "AD",
+ "017045",
+ InstitutionalRole.Administrative);
+
private static readonly UserIdentity Student = new(
"AL123456",
"AL",
@@ -101,6 +107,91 @@ public sealed class NtlmCredentialValidatorTests
handler.RequestPaths[4]);
}
+ [Fact]
+ public async Task VerifiedAdministrativeProfileIsEnrichedFromPersonalAndLocationPages()
+ {
+ SequenceHandler handler = new(
+ Challenge(),
+ Response(HttpStatusCode.OK),
+ Response(
+ HttpStatusCode.OK,
+ """
+ 017045 - NOMBRE ANTERIOR
+ persona@lasalle.mx
+ ANALISTA
+ """),
+ Response(
+ HttpStatusCode.OK,
+ """
+
+
+
+ """),
+ Response(
+ HttpStatusCode.OK,
+ """
+
+
+
+
+
+
+ """));
+ NtlmCredentialValidator validator = CreateValidator(handler);
+
+ NtlmValidationResult result = await validator.ValidateAsync(
+ Administrative,
+ "test-password",
+ TestContext.Current.CancellationToken);
+
+ Assert.Equal(NtlmValidationStatus.Valid, result.Status);
+ Assert.NotNull(result.Profile);
+ Assert.Equal("017045", result.Profile.EmployeeNumber);
+ Assert.Equal("María del Carmen de la Fuente O'Connor", result.Profile.DisplayName);
+ Assert.Equal("María del Carmen", result.Profile.GivenName);
+ Assert.Equal("de la Fuente O'Connor", result.Profile.Surname);
+ Assert.Equal("persona@lasalle.mx", result.Profile.Email);
+ Assert.Equal("Analista", result.Profile.JobTitle);
+ Assert.Equal("Calle del Sol 15\r\nFlorida", result.Profile.StreetAddress);
+ Assert.Equal("Álvaro Obregón", result.Profile.City);
+ Assert.Equal("Ciudad de México", result.Profile.State);
+ Assert.Equal("01000", result.Profile.PostalCode);
+ Assert.Equal(
+ [
+ "/psulsa/",
+ "/psulsa/",
+ "/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx",
+ "/psulsa/gadmon/capitalhumano/datos/personales.aspx",
+ "/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx"
+ ],
+ handler.RequestPaths);
+ }
+
+ [Fact]
+ public async Task AdministrativeSupplementalPagesRequireAVerifiedEmployeeNumber()
+ {
+ SequenceHandler handler = new(
+ Challenge(),
+ Response(HttpStatusCode.OK),
+ Response(HttpStatusCode.OK, "Datos inesperados"));
+ NtlmCredentialValidator validator = CreateValidator(handler);
+
+ NtlmValidationResult result = await validator.ValidateAsync(
+ Administrative,
+ "test-password",
+ TestContext.Current.CancellationToken);
+
+ Assert.Equal(NtlmValidationStatus.Valid, result.Status);
+ Assert.Null(result.Profile);
+ Assert.Equal(3, handler.RequestPaths.Count);
+ }
+
private static NtlmCredentialValidator CreateValidator(SequenceHandler handler)
{
BrokerOptions options = new()
@@ -109,6 +200,9 @@ public sealed class NtlmCredentialValidatorTests
{
Endpoint = "https://sgu.example/",
AuthenticationPath = "/psulsa/",
+ AdministrativeProfilePath = "/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx",
+ AdministrativePersonalProfilePath = "/psulsa/gadmon/capitalhumano/datos/personales.aspx",
+ AdministrativeLocationProfilePath = "/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx",
StudentProfilePath = "/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx",
AllowedRedirectHosts = ["sgu.example"],
TimeoutSeconds = 5,
@@ -136,10 +230,12 @@ public sealed class NtlmCredentialValidatorTests
return response;
}
- private static HttpResponseMessage Response(HttpStatusCode statusCode) =>
+ private static HttpResponseMessage Response(
+ HttpStatusCode statusCode,
+ string content = "") =>
new(statusCode)
{
- Content = new StringContent("")
+ Content = new StringContent(content)
};
private sealed class SequenceHandler(params HttpResponseMessage[] responses) : HttpMessageHandler