Files
james.maesandClaude Sonnet 5.5 e4639e3cca chore: integrate printing standards and strict Java compiler checks
Preserve current rendering, operator and retained-source behavior while integrating script quality, meaningful headers and strict Error Prone/NullAway contracts. Production and test compilation and the 189-case verification gate pass with zero failures and one intentional workload skip.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-01 16:51:18 -06:00

136 lines
6.4 KiB
C#

// Copyright 2026 Kingsrook, LLC
// SPDX-License-Identifier: Apache-2.0
using System.Net;
using System.Text.Json.Nodes;
namespace Printing.NativeAuth;
/// <summary>Uses Authentik's real synthetic-user login stages; never fabricates a session or code.</summary>
internal static class ProviderFlow
{
internal static string? LastComponent
{
get;
private set;
}
/// <summary>Follow bounded provider navigation while confining redirects to fixture origins.</summary>
internal static async Task AuthenticateAsync(Uri authorization, Uri origin, Uri callback, string username, string password, CancellationToken token)
{
using var browser = new FixtureHttp(origin, cookies: true);
using var callbackHttp = new FixtureHttp(new Uri(callback.GetLeftPart(UriPartial.Authority)));
Uri next = authorization;
for(int hop = 0; hop < 24; hop++)
{
if(string.Equals(next.GetLeftPart(UriPartial.Authority), callback.GetLeftPart(UriPartial.Authority), StringComparison.Ordinal))
{
if(!string.Equals(next.AbsolutePath, callback.AbsolutePath, StringComparison.Ordinal))
{
throw new InvalidOperationException("Unexpected callback path.");
}
FixtureHttp.Reply result = await callbackHttp.SendAsync("GET", next, null, token);
if(result.Status != HttpStatusCode.OK)
{
throw new InvalidOperationException("The HTTPS callback receiver refused the provider redirect.");
}
return;
}
if(!string.Equals(next.GetLeftPart(UriPartial.Authority), origin.GetLeftPart(UriPartial.Authority), StringComparison.Ordinal))
{
throw new InvalidOperationException("A provider redirect left the fixture.");
}
FixtureHttp.Reply page = await browser.SendAsync("GET", next, null, token);
if(page.Status is HttpStatusCode.Redirect or HttpStatusCode.SeeOther or HttpStatusCode.TemporaryRedirect)
{
next = new Uri(next, page.Location ?? throw new InvalidOperationException("Missing provider redirect."));
continue;
}
if(page.Status != HttpStatusCode.OK || !next.AbsolutePath.StartsWith("/if/flow/", StringComparison.Ordinal))
{
throw new InvalidOperationException("Unexpected provider login navigation.");
}
string slug = next.AbsolutePath["/if/flow/".Length..].Trim('/');
if(slug.Length == 0 || slug.Any(character => !char.IsAsciiLetterOrDigit(character) && character != '-'))
{
throw new InvalidOperationException("Unexpected provider flow identifier.");
}
var executor = new Uri(origin, "/api/v3/flows/executor/" + slug + "/?query=" + Uri.EscapeDataString(next.Query.TrimStart('?')));
next = await CompleteStagesAsync(browser, executor, origin, username, password, token);
}
throw new InvalidOperationException("Provider redirect bound exceeded.");
}
/// <summary>Answer at most ten real provider challenges and return its declared redirect.</summary>
private static async Task<Uri> CompleteStagesAsync(FixtureHttp browser, Uri executor, Uri origin, string username, string password, CancellationToken token)
{
JsonObject challenge = await ChallengeAsync(browser, executor, await browser.SendAsync("GET", executor, null, token), token);
for(int stage = 0; stage < 10; stage++)
{
string component = ProviderSetup.Text(challenge, "component");
LastComponent = component switch
{
"ak-stage-identification" => "identification",
"ak-stage-password" => "password",
"ak-stage-consent" => "consent",
"xak-flow-redirect" => "redirect",
"ak-stage-access-denied" => "access-denied",
"ak-stage-flow-error" => "flow-error",
_ => "unsupported",
};
if(string.Equals(component, "xak-flow-redirect", StringComparison.Ordinal))
{
return new Uri(origin, ProviderSetup.Text(challenge, "to"));
}
JsonObject response = component switch
{
"ak-stage-identification" => new JsonObject { ["component"] = component, ["uid_field"] = username },
"ak-stage-password" => new JsonObject { ["component"] = component, ["password"] = password },
"ak-stage-consent" => new JsonObject { ["component"] = component, ["consent"] = true },
_ => throw new InvalidOperationException("The provider requested an unsupported test login stage."),
};
challenge = await ChallengeAsync(browser, executor, await browser.SendAsync("POST", executor, response, token), token);
if(challenge["response_errors"] is JsonObject errors && errors.Count != 0)
{
throw new InvalidOperationException("The real provider rejected the synthetic login response.");
}
}
throw new InvalidOperationException("Provider stage bound exceeded.");
}
/// <summary>Follow only same-executor POST/redirect/GET responses and require a JSON challenge.</summary>
private static async Task<JsonObject> ChallengeAsync(FixtureHttp browser, Uri executor, FixtureHttp.Reply response, CancellationToken token)
{
// Authentik uses POST/redirect/GET when a real stage accepts its answer.
// Follow only that same HTTPS executor; other navigation uses typed flow redirects.
for(int redirect = 0; redirect < 8; redirect++)
{
if(response.Status == HttpStatusCode.OK)
{
return FixtureHttp.RequireJson(response, HttpStatusCode.OK);
}
if(response.Status is not (HttpStatusCode.Redirect or HttpStatusCode.SeeOther) || response.Location is null)
{
throw new InvalidOperationException("Unexpected provider stage response.");
}
var location = new Uri(executor, response.Location);
if(!string.Equals(location.GetLeftPart(UriPartial.Authority), executor.GetLeftPart(UriPartial.Authority), StringComparison.Ordinal)
|| !string.Equals(location.AbsolutePath, executor.AbsolutePath, StringComparison.Ordinal))
{
throw new InvalidOperationException("A stage redirect left its exact HTTPS executor.");
}
response = await browser.SendAsync("GET", location, null, token);
}
throw new InvalidOperationException("Provider stage redirect bound exceeded.");
}
}