Enrich administrative profiles from SGU

This commit is contained in:
2026-09-03 12:21:43 -06:00
parent d50a4d9895
commit 403132f869
12 changed files with 609 additions and 22 deletions
@@ -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<InstitutionalProfile> TryEnrichAdministrativeProfileAsync(
HttpClient client,
InstitutionalProfile verifiedProfile,
HashSet<string> allowedHosts,
CancellationToken timeoutToken,
CancellationToken requestCancellationToken,
Stopwatch elapsed)
{
InstitutionalProfile profile = verifiedProfile;
(string Path, Func<string, InstitutionalProfile?> Parser)[] pages =
[
(options.AdministrativePersonalProfilePath, SguProfileParser.ParseAdministrativePersonal),
(options.AdministrativeLocationProfilePath, SguProfileParser.ParseAdministrativeLocation)
];
foreach ((string path, Func<string, InstitutionalProfile?> 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<string?> TryFetchAdditionalProfilePageAsync(
HttpClient client,
Uri requestedUri,
HashSet<string> 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<InstitutionalProfile?> TryReadProfileAsync(
HttpResponseMessage response,
UserIdentity identity,