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>
136 lines
6.4 KiB
C#
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.");
|
|
}
|
|
}
|