Resolve SGU staff addresses and provision local student user

This commit is contained in:
2026-09-09 09:48:06 -06:00
parent 8baa47fe1e
commit a6bd625e4e
15 changed files with 728 additions and 28 deletions
@@ -0,0 +1,20 @@
namespace SGU.AuthBroker.Core.Profiles;
public sealed record SguAdministrativeLocationSelection(
string? StateId = null,
string? MunicipalityId = null,
string? NeighborhoodId = null,
string? StateName = null,
string? MunicipalityName = null,
string? NeighborhoodName = null,
string? PostalCode = null)
{
public bool HasValues =>
StateId is not null ||
MunicipalityId is not null ||
NeighborhoodId is not null ||
StateName is not null ||
MunicipalityName is not null ||
NeighborhoodName is not null ||
PostalCode is not null;
}
@@ -1,6 +1,7 @@
using System.Net;
using System.Net.Mail;
using System.Text;
using System.Text.Json;
namespace SGU.AuthBroker.Core.Profiles;
@@ -102,7 +103,14 @@ public static class SguProfileParser
return profile.HasValues ? profile : null;
}
public static InstitutionalProfile? ParseAdministrativeLocation(string html)
public static InstitutionalProfile? ParseAdministrativeLocation(string html) =>
ParseAdministrativeLocation(html, null, null, null);
public static InstitutionalProfile? ParseAdministrativeLocation(
string html,
SguAdministrativeLocationSelection? selection,
string? localitiesJson,
string? neighborhoodsJson)
{
ArgumentNullException.ThrowIfNull(html);
@@ -112,18 +120,79 @@ public static class SguProfileParser
string? interiorNumber = NormalizeAddressUnit(
ExtractInputValue(html, AdministrativeInteriorNumberId));
string? streetLine = BuildAdministrativeStreetLine(street, exteriorNumber, interiorNumber);
string? neighborhood = NormalizeTitle(
ExtractSelectedOptionText(html, AdministrativeNeighborhoodId),
256);
string? neighborhood = NormalizeTitle(FirstNonEmpty(
selection?.NeighborhoodName,
ResolveNeighborhoodName(neighborhoodsJson, selection),
ExtractSelectedOptionText(html, AdministrativeNeighborhoodId)), 256);
string? city = NormalizeTitle(FirstNonEmpty(
selection?.MunicipalityName,
ResolveLocalityName(localitiesJson, selection),
ExtractSelectedOptionText(html, AdministrativeCityId)), 128);
string? state = NormalizeTitle(FirstNonEmpty(
selection?.StateName,
ExtractOptionTextByValue(html, AdministrativeStateId, selection?.StateId),
ExtractSelectedOptionText(html, AdministrativeStateId)), 128);
string? postalCode = NormalizePostalCode(FirstNonEmpty(
selection?.PostalCode,
ExtractInputValue(html, AdministrativePostalCodeId)));
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)));
City: city,
State: state,
PostalCode: postalCode);
return profile.HasValues ? profile : null;
}
public static SguAdministrativeLocationSelection? ParseAdministrativeLocationSelection(
string json,
string? expectedPostalCode)
{
ArgumentNullException.ThrowIfNull(json);
string? expected = NormalizePostalCode(expectedPostalCode);
try
{
using JsonDocument document = JsonDocument.Parse(json);
if (!TryGetPageMethodArray(document.RootElement, out JsonElement values))
{
return null;
}
foreach (JsonElement value in values.EnumerateArray())
{
string? postalCode = NormalizePostalCode(GetJsonString(value, "p_Cp"));
if (expected is not null &&
!string.Equals(postalCode, expected, StringComparison.Ordinal))
{
continue;
}
SguAdministrativeLocationSelection selection = new(
StateId: NormalizeCatalogId(GetJsonString(value, "p_IdEstado")),
MunicipalityId: NormalizeCatalogId(GetJsonString(value, "p_IdMunicipio")),
NeighborhoodId: NormalizeCatalogId(GetJsonString(value, "p_IdCP")),
StateName: Limit(GetJsonString(value, "p_NombreEstado"), 128),
MunicipalityName: Limit(GetJsonString(value, "p_NombreMunicipio"), 128),
NeighborhoodName: Limit(FirstNonEmpty(
GetJsonString(value, "p_NombreColonia"),
GetJsonString(value, "p_NombreAsentamiento"),
GetJsonString(value, "p_Nombre")), 256),
PostalCode: postalCode);
if (selection.HasValues)
{
return selection;
}
}
}
catch (JsonException)
{
return null;
}
return null;
}
public static InstitutionalProfile? ParseStudent(string html, string expectedStudentNumber)
{
ArgumentNullException.ThrowIfNull(html);
@@ -350,6 +419,186 @@ public static class SguProfileParser
return null;
}
private static string? ExtractOptionTextByValue(string html, string id, string? expectedValue)
{
if (string.IsNullOrWhiteSpace(expectedValue))
{
return null;
}
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("</select", contentStart, StringComparison.OrdinalIgnoreCase);
if (openingTagIndex < 0 || contentEnd < 0)
{
return null;
}
string optionsHtml = html[contentStart..contentEnd];
int searchFrom = 0;
while (searchFrom < optionsHtml.Length)
{
int optionStart = optionsHtml.IndexOf("<option", searchFrom, StringComparison.OrdinalIgnoreCase);
if (optionStart < 0)
{
break;
}
int optionTagEnd = optionsHtml.IndexOf('>', optionStart);
int optionEnd = optionTagEnd < 0
? -1
: optionsHtml.IndexOf("</option", optionTagEnd + 1, StringComparison.OrdinalIgnoreCase);
if (optionTagEnd < 0 || optionEnd < 0)
{
break;
}
string optionTag = optionsHtml[optionStart..(optionTagEnd + 1)];
string? optionValue = ExtractAttributeValue(optionTag, "value");
if (string.Equals(optionValue, expectedValue, StringComparison.Ordinal))
{
return NormalizeText(optionsHtml[(optionTagEnd + 1)..optionEnd]);
}
searchFrom = optionEnd + "</option".Length;
}
return null;
}
private static string? ResolveLocalityName(
string? json,
SguAdministrativeLocationSelection? selection)
{
if (string.IsNullOrWhiteSpace(json) ||
string.IsNullOrWhiteSpace(selection?.MunicipalityId))
{
return null;
}
try
{
using JsonDocument document = JsonDocument.Parse(json);
if (!TryGetPageMethodArray(document.RootElement, out JsonElement values))
{
return null;
}
foreach (JsonElement value in values.EnumerateArray())
{
if (string.Equals(
NormalizeCatalogId(GetJsonString(value, "Id_Municipio")),
selection.MunicipalityId,
StringComparison.Ordinal) &&
(selection.StateId is null || string.Equals(
NormalizeCatalogId(GetJsonString(value, "ID_Estado")),
selection.StateId,
StringComparison.Ordinal)))
{
return GetJsonString(value, "Nombre");
}
}
}
catch (JsonException)
{
return null;
}
return null;
}
private static string? ResolveNeighborhoodName(
string? json,
SguAdministrativeLocationSelection? selection)
{
if (string.IsNullOrWhiteSpace(json) || selection is null)
{
return null;
}
try
{
using JsonDocument document = JsonDocument.Parse(json);
if (!TryGetPageMethodArray(document.RootElement, out JsonElement values))
{
return null;
}
List<string> postalCodeMatches = [];
foreach (JsonElement value in values.EnumerateArray())
{
string? neighborhoodId = NormalizeCatalogId(GetJsonString(value, "p_IdCP"));
string? postalCode = NormalizePostalCode(GetJsonString(value, "p_Cp"));
string? name = GetJsonString(value, "p_Nombre");
if (name is null)
{
continue;
}
if (selection.NeighborhoodId is not null &&
string.Equals(neighborhoodId, selection.NeighborhoodId, StringComparison.Ordinal))
{
return name;
}
if (selection.PostalCode is not null &&
string.Equals(postalCode, selection.PostalCode, StringComparison.Ordinal))
{
postalCodeMatches.Add(name);
}
}
return postalCodeMatches.Count == 1 ? postalCodeMatches[0] : null;
}
catch (JsonException)
{
return null;
}
}
private static bool TryGetPageMethodArray(JsonElement root, out JsonElement values)
{
values = default;
return root.ValueKind == JsonValueKind.Object &&
root.TryGetProperty("d", out values) &&
values.ValueKind == JsonValueKind.Array;
}
private static string? GetJsonString(JsonElement value, string propertyName)
{
if (value.ValueKind != JsonValueKind.Object ||
!value.TryGetProperty(propertyName, out JsonElement property))
{
return null;
}
return property.ValueKind switch
{
JsonValueKind.String => property.GetString(),
JsonValueKind.Number => property.GetRawText(),
_ => null
};
}
private static string? NormalizeCatalogId(string? value)
{
string? candidate = value?.Trim();
return string.IsNullOrEmpty(candidate) ||
candidate.Length > 32 ||
!candidate.All(char.IsAsciiLetterOrDigit)
? null
: candidate;
}
private static string? FirstNonEmpty(params string?[] values) =>
values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
private static string? FindOpeningTag(string html, string tagName, string id)
=> FindOpeningTagByAttribute(html, tagName, "id", id);
@@ -1,5 +1,7 @@
using System.Diagnostics;
using System.Net;
using System.Text;
using System.Text.Json;
using SGU.AuthBroker.Core.Authentication;
using SGU.AuthBroker.Core.Identity;
using SGU.AuthBroker.Core.Profiles;
@@ -431,9 +433,6 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
pages.Add((
options.AdministrativePersonalProfilePath,
SguProfileParser.ParseAdministrativePersonal));
pages.Add((
options.AdministrativeLocationProfilePath,
SguProfileParser.ParseAdministrativeLocation));
foreach ((string path, Func<string, InstitutionalProfile?> parser) in pages)
{
@@ -474,7 +473,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
"SGU optional staff profile enrichment for role {Role} reached its total timeout after {ElapsedMilliseconds} ms; preserving fields already collected.",
identity.Role,
elapsed.ElapsedMilliseconds);
break;
return profile;
}
catch (Exception exception)
{
@@ -487,6 +486,142 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
}
}
return await TryEnrichStaffLocationAsync(
client,
profile,
identity,
allowedHosts,
timeoutToken,
requestCancellationToken,
elapsed).ConfigureAwait(false);
}
private async Task<InstitutionalProfile> TryEnrichStaffLocationAsync(
HttpClient client,
InstitutionalProfile profile,
UserIdentity identity,
HashSet<string> allowedHosts,
CancellationToken timeoutToken,
CancellationToken requestCancellationToken,
Stopwatch elapsed)
{
string path = options.AdministrativeLocationProfilePath;
Uri locationPageUri = GetProfileUri(path);
try
{
string? html = await TryFetchAdditionalProfilePageAsync(
client,
locationPageUri,
allowedHosts,
timeoutToken).ConfigureAwait(false);
if (html is null)
{
logger.LogWarning(
BrokerEventIds.ProfilePageUnavailable,
"Optional SGU profile page {Path} did not return usable HTML for role {Role}; preserving fields already collected.",
path,
identity.Role);
return profile;
}
InstitutionalProfile? staticLocation = SguProfileParser.ParseAdministrativeLocation(html);
if (staticLocation is null)
{
logger.LogWarning(
BrokerEventIds.ProfileHtmlUnexpected,
"Optional SGU profile page {Path} returned HTML without its supported field IDs for role {Role}; preserving fields already collected.",
path,
identity.Role);
return profile;
}
profile = profile.Overlay(staticLocation);
if (string.IsNullOrWhiteSpace(staticLocation.PostalCode))
{
return profile;
}
string? directionJson = await TryPostProfilePageMethodAsync(
client,
GetAdministrativeLocationMethodUri("GetDireccion"),
locationPageUri,
new Dictionary<string, string>
{
["CodigoPostal"] = staticLocation.PostalCode
},
allowedHosts,
timeoutToken).ConfigureAwait(false);
if (directionJson is null)
{
return profile;
}
SguAdministrativeLocationSelection? selection =
SguProfileParser.ParseAdministrativeLocationSelection(
directionJson,
staticLocation.PostalCode);
if (selection is null)
{
logger.LogWarning(
BrokerEventIds.ProfileHtmlUnexpected,
"SGU location method GetDireccion returned an unexpected payload for role {Role}; preserving the static address fields.",
identity.Role);
return profile;
}
string? localitiesJson = null;
if (!string.IsNullOrWhiteSpace(selection.StateId))
{
localitiesJson = await TryPostProfilePageMethodAsync(
client,
GetAdministrativeLocationMethodUri("GetLocalidadListado"),
locationPageUri,
new Dictionary<string, string>
{
["pIdEstado"] = selection.StateId
},
allowedHosts,
timeoutToken).ConfigureAwait(false);
}
string? neighborhoodsJson = await TryPostProfilePageMethodAsync(
client,
GetAdministrativeLocationMethodUri("GetColoniasListado"),
locationPageUri,
new Dictionary<string, string>
{
["pIdEstado"] = string.Empty,
["pLocalidad"] = string.Empty,
["CodigoPostal"] = selection.PostalCode ?? staticLocation.PostalCode
},
allowedHosts,
timeoutToken).ConfigureAwait(false);
InstitutionalProfile? resolvedLocation = SguProfileParser.ParseAdministrativeLocation(
html,
selection,
localitiesJson,
neighborhoodsJson);
return profile.Overlay(resolvedLocation);
}
catch (OperationCanceledException) when (!requestCancellationToken.IsCancellationRequested)
{
logger.LogWarning(
BrokerEventIds.ProfileEnrichmentTimeout,
"SGU optional staff location enrichment for role {Role} reached its total timeout after {ElapsedMilliseconds} ms; preserving fields already collected.",
identity.Role,
elapsed.ElapsedMilliseconds);
}
catch (Exception exception)
{
logger.LogWarning(
BrokerEventIds.ProfileEnrichmentFailure,
exception,
"SGU optional staff location enrichment failed for role {Role} after {ElapsedMilliseconds} ms; preserving fields already collected.",
identity.Role,
elapsed.ElapsedMilliseconds);
}
return profile;
}
@@ -545,6 +680,46 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
return null;
}
private async Task<string?> TryPostProfilePageMethodAsync(
HttpClient client,
Uri requestedUri,
Uri referrerUri,
IReadOnlyDictionary<string, string> payload,
HashSet<string> allowedHosts,
CancellationToken cancellationToken)
{
if (!IsAllowedHttpsUri(requestedUri, allowedHosts) ||
!IsAllowedHttpsUri(referrerUri, allowedHosts))
{
return null;
}
using HttpRequestMessage request = new(HttpMethod.Post, requestedUri);
request.Headers.Referrer = referrerUri;
request.Content = new StringContent(
JsonSerializer.Serialize(payload),
Encoding.UTF8,
"application/json");
using HttpResponseMessage response = await client
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
int statusCode = (int)response.StatusCode;
if (statusCode is >= 200 and < 300)
{
return await ReadLimitedStringAsync(
response.Content,
options.MaxProfileBytes,
cancellationToken).ConfigureAwait(false);
}
logger.LogWarning(
BrokerEventIds.ProfilePageUnavailable,
"Optional SGU profile method {Path} returned HTTP {StatusCode}.",
requestedUri.AbsolutePath,
statusCode);
return null;
}
private static void AddCredential(
Uri uri,
CredentialCache credentialCache,
@@ -658,6 +833,12 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
private Uri GetProfileUri(string path) =>
new(new Uri(options.Endpoint, UriKind.Absolute), path);
private Uri GetAdministrativeLocationMethodUri(string methodName)
{
Uri pageUri = GetProfileUri(options.AdministrativeLocationProfilePath);
return new Uri($"{pageUri.GetLeftPart(UriPartial.Path).TrimEnd('/')}/{methodName}");
}
private async Task<InstitutionalProfile?> TryReadProfileAsync(
HttpResponseMessage response,
UserIdentity identity,