using System.Text.RegularExpressions;
using System.Web;
using DodoSSH.Client.Auth;
namespace DodoSSH.SystemTests;
///
/// Signs in to a real Keycloak by driving its login form over HTTP.
///
///
///
/// Stands in for the system browser, and only for the browser — everything else in the flow is the real
/// thing. The authorization request, the login form, the redirect back to the loopback listener, the code
/// exchange and PKCE verification all happen exactly as they would for a person clicking through.
///
///
/// Everything up to the redirect is awaited; the final hop is not. That split is not tidiness.
/// OidcClient awaits the launcher before it awaits the callback, and the last hop of this flow is a
/// request to that callback — completing it inline would block on a request nobody is reading yet
/// and the sign-in would deadlock instead of failing. Awaiting the earlier steps is what makes a rejected
/// authorization request surface immediately, with Keycloak's own words, rather than as a five-minute wait
/// for a browser that was never going to arrive.
///
///
internal sealed partial class ScriptedBrowser(string username, string password) : IBrowserLauncher
{
/// How many times a sign-in was driven, so the key binding's second one is visible.
internal int SignInCount { get; private set; }
///
public async Task OpenAsync(Uri url, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(url);
SignInCount++;
// Cookies are carried by hand, and automatic handling is off. That is not a shortcut — it is
// required, and the reason is worth knowing:
//
// Keycloak marks its authentication-session cookies `Secure; SameSite=None`, because SameSite=None
// is only legal alongside Secure. Over a development stack served on plain HTTP, a
// spec-conformant client refuses to store a Secure cookie from an insecure origin, so
// CookieContainer silently drops every one of them and the login POST comes back 400 with no
// explanation. Browsers do complete this flow, because they treat loopback as a trustworthy
// origin and make the exception. Carrying the cookies manually emulates that exception
// deliberately, in one visible place, rather than looking like broken cookie handling.
var handler = new HttpClientHandler { AllowAutoRedirect = false, UseCookies = false };
var http = new HttpClient(handler);
try
{
var redirect = await SubmitCredentialsAsync(http, url, cancellationToken)
.ConfigureAwait(false);
// Deliberately not awaited: this is the request the loopback listener is waiting for, and it
// is only read after this method returns. Ownership of the client passes to the continuation.
_ = DeliverAsync(http, handler, redirect, cancellationToken);
}
catch
{
http.Dispose();
handler.Dispose();
throw;
}
}
/// Fetches the login page and posts the credentials, returning where Keycloak sends us.
private async Task SubmitCredentialsAsync(
HttpClient http,
Uri authorizationUrl,
CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, authorizationUrl);
using var opened = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
var cookies = CollectCookies(opened);
var loginPage = await opened.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var action = ExtractLoginAction(loginPage);
using var credentials = new FormUrlEncodedContent(
new Dictionary(StringComparer.Ordinal)
{
["username"] = username,
["password"] = password,
["credentialId"] = string.Empty,
});
using var login = new HttpRequestMessage(HttpMethod.Post, action) { Content = credentials };
login.Headers.Add("Cookie", cookies);
using var posted = await http.SendAsync(login, cancellationToken).ConfigureAwait(false);
if (posted.Headers.Location is { } redirect)
{
return redirect;
}
// Keycloak answers a rejected login with another page rather than a header, so the reason is only
// in the body. Reporting the status alone would be almost useless.
var body = await posted.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException(
$"Keycloak answered the login form with {(int)posted.StatusCode} and no redirect. It said: "
+ Summarise(body));
}
///
/// Only the name=value part of each Set-Cookie, which is all a request may send back.
///
private static string CollectCookies(HttpResponseMessage response)
{
if (!response.Headers.TryGetValues("Set-Cookie", out var values))
{
return string.Empty;
}
var pairs = values
.Select(value => value.Split(';', 2)[0].Trim())
.Where(pair => pair.Length > 0);
return string.Join("; ", pairs);
}
private static async Task DeliverAsync(
HttpClient http,
HttpClientHandler handler,
Uri redirect,
CancellationToken cancellationToken)
{
try
{
using var delivered = await http.GetAsync(redirect, cancellationToken).ConfigureAwait(false);
}
catch (Exception)
{
// Nothing useful to do here. If the code never arrives, the sign-in fails on its own timeout
// and rethrowing on an unobserved task would take the test host down with it instead.
}
finally
{
http.Dispose();
handler.Dispose();
}
}
///
/// The form's action carries the session code and the execution id, so it cannot be constructed — it
/// has to be read back out of the page, and it arrives HTML-escaped.
///
private static Uri ExtractLoginAction(string loginPage)
{
var match = LoginFormAction().Match(loginPage);
if (!match.Success)
{
throw new InvalidOperationException(
"Could not find Keycloak's login form, so the authorization request was rejected before a "
+ $"login was ever offered. Keycloak said: {Summarise(loginPage)}");
}
return new Uri(HttpUtility.HtmlDecode(match.Groups["action"].Value), UriKind.Absolute);
}
/// Surfaces Keycloak's own error text, which is the only useful part of a rejection page.
private static string Summarise(string page)
{
var messages = ErrorMessage().Matches(page)
.Select(match => match.Groups["text"].Value.Trim())
.Where(text => text.Length > 0)
.Distinct(StringComparer.Ordinal)
.ToArray();
if (messages.Length > 0)
{
return string.Join(" / ", messages.Select(text => $"\"{text}\""));
}
var title = PageTitle().Match(page);
return title.Success
? $"a page titled \"{title.Groups["text"].Value.Trim()}\" with no error text"
: $"nothing recognisable, in {page.Length} characters";
}
// A timeout because the input is a page from a server, and an unbounded backtrack on untrusted
// input is a hang rather than a failure.
[GeneratedRegex(
"""