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( """]*id="kc-form-login"[^>]*action="(?[^"]+)""", RegexOptions.IgnoreCase, matchTimeoutMilliseconds: 2000)] private static partial Regex LoginFormAction(); // Matches both shapes Keycloak uses: the per-field "input-error" spans on a login page, and the // "kc-feedback" / alert text on a rejected request. [GeneratedRegex( """(?:id|class)="[^"]*(?:input-error|kc-feedback-text|pf-v5-c-alert__title)[^"]*"[^>]*>(?[^<]{1,300})<""", RegexOptions.IgnoreCase, matchTimeoutMilliseconds: 2000)] private static partial Regex ErrorMessage(); [GeneratedRegex("""(?<text>[^<]{0,200})""", RegexOptions.IgnoreCase, matchTimeoutMilliseconds: 2000)] private static partial Regex PageTitle(); }