Public Access
The whole vertical slice now runs against a real Keycloak, a real API, a real PostgreSQL and a real sshd: sign in through the browser flow, enroll with the identity-provider key binding, unlock, create a host, sync it, read it back on a second machine, unlock again with no network, accept an unseen host key, and open an interactive shell. Opt-in, because it needs the development stack; skipped with a message naming the commands. It found two bugs on its first run, and both are the same class: two sides of a stub agreeing with each other about something the specification never said. **The API never applied DodoSshJsonContext to its HTTP JSON options.** Minimal APIs therefore used the framework's web defaults, which write an enum as a number. Every request DTO carrying one failed to bind against a client writing the specified string form — which is the entire sync surface, unreachable from the real client, with a 400 naming only the parameter. The documented guarantee that request bodies reject unmapped members was likewise not in effect anywhere. Nothing caught it because the API tests posted with PostAsJsonAsync's defaults, so they and the server had independently settled on integers. Those tests now serialise through the contract, which is the deeper fix: removing the new configuration fails 13 of them. Copying settings into options a host owns is itself the hazard the context warns about, so ApplyTo lives beside the settings it mirrors and ApplyToTests pins the transformation, including that inserting the resolver leaves the caller's own in place. **The realm registered a loopback redirect URI Keycloak rejects.** `http://127.0.0.1:*/callback` looks more explicit than the RFC 8252 form and is broken: Keycloak's wildcards are trailing-only, so the `*` parses as a literal port and every authorization request came back "Invalid parameter: redirect_uri". Providers ignore the port for loopback hosts, which is the whole mechanism, so the correct registration is `http://127.0.0.1/callback` — path pinned, port free. The value the server advertises through the discovery document said the same wrong thing and now says the right one. Two smaller things, both documented in docs/platform-flags.md: - --import-realm skips a realm that already exists, so editing the realm file and restarting Keycloak changes nothing and serves stale configuration. The container has to be recreated. The compose comment claimed the opposite. - Keycloak marks its session cookies Secure even over plain HTTP, because SameSite=None requires it. A spec-conformant client drops them and the login POST answers 400 with no message; browsers complete the flow only because they exempt loopback. Harmless for the product, fatal for automation, so ScriptedBrowser carries the cookies by hand and says why. Also: the server enforces a 64 MiB floor on the passphrase KDF, so this suite cannot use the 8 MiB profile the other client suites take for speed. Those only get away with it because their in-memory servers have no policy — worth knowing rather than rediscovering. 638 tests. The solution-wide run stays green with the stack down: exit code 8 means "no tests ran", which the platform reports as failure, so the opt-in project ignores exactly that code.
208 lines
8.4 KiB
C#
208 lines
8.4 KiB
C#
using System.Text.RegularExpressions;
|
|
using System.Web;
|
|
using DodoSSH.Client.Auth;
|
|
|
|
namespace DodoSSH.SystemTests;
|
|
|
|
/// <summary>
|
|
/// Signs in to a real Keycloak by driving its login form over HTTP.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Everything up to the redirect is awaited; the final hop is not.</b> That split is not tidiness.
|
|
/// <c>OidcClient</c> awaits the launcher before it awaits the callback, and the last hop of this flow is a
|
|
/// request <em>to</em> 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed partial class ScriptedBrowser(string username, string password) : IBrowserLauncher
|
|
{
|
|
/// <summary>How many times a sign-in was driven, so the key binding's second one is visible.</summary>
|
|
internal int SignInCount { get; private set; }
|
|
|
|
/// <inheritdoc />
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>Fetches the login page and posts the credentials, returning where Keycloak sends us.</summary>
|
|
private async Task<Uri> 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<string, string>(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));
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Only the <c>name=value</c> part of each <c>Set-Cookie</c>, which is all a request may send back.
|
|
/// </remarks>
|
|
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();
|
|
}
|
|
}
|
|
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
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);
|
|
}
|
|
|
|
/// <remarks>Surfaces Keycloak's own error text, which is the only useful part of a rejection page.</remarks>
|
|
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(
|
|
"""<form[^>]*id="kc-form-login"[^>]*action="(?<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)[^"]*"[^>]*>(?<text>[^<]{1,300})<""",
|
|
RegexOptions.IgnoreCase,
|
|
matchTimeoutMilliseconds: 2000)]
|
|
private static partial Regex ErrorMessage();
|
|
|
|
[GeneratedRegex("""<title>(?<text>[^<]{0,200})</title>""", RegexOptions.IgnoreCase,
|
|
matchTimeoutMilliseconds: 2000)]
|
|
private static partial Regex PageTitle();
|
|
}
|