Files
DodoSSH/tests/DodoSSH.Client.Auth.Tests/FakeBrowser.cs
jaap-jan 94f66be5e8 Add the OIDC client: PKCE loopback sign-in and the key binding flow
Authorization Code with PKCE on a loopback redirect, per RFC 6749, RFC 7636
and RFC 8252. Zero package references: the flow is fully specified, and the
one thing a library would own for us -- nonce generation and validation -- is
exactly what the key binding needs to control. Duende's OidcClient generates
and validates its own nonce as an internal detail, and the binding requires
the nonce be a specific value: the hash of the key statement being enrolled.
Fighting that is worse than owning the flow.

AuthorizeKeyBindingAsync is the client half of the primary trust anchor. It
runs a second authorization with nonce set to the statement hash and
prompt=login, so the ID token that returns is the provider's signature over
exactly those public keys, attesting to a user present now rather than to a
session opened at some unknown earlier time. It requests only openid -- a
second refresh token would be one more long-lived credential for no benefit
-- and rejects a token whose nonce is not the one it asked for, because
enrolling that would store evidence verifying against keys we are not
publishing.

The nonce is read without validating the ID token's signature. Sanctioned by
OIDC Core 3.1.3.7: for a token received by direct communication with the
token endpoint, TLS server authentication may stand in for signature
checking. That reasoning does not extend to another user's binding, which
arrives via the DodoSSH server and must be verified against JWKS fetched
directly -- the directory work in M3.

Raw TcpListener rather than HttpListener for the redirect: an ephemeral port
can be bound and read atomically instead of picking one and hoping it is
still free, there is no HTTP.SYS URL-ACL question on Windows, and the whole
surface is one request line. It answers 404 on other paths and keeps
waiting, because a browser asks for /favicon.ico first and treating that as
the callback would abort every sign-in. 127.0.0.1 rather than localhost: RFC
8252 permits either, but the name resolves through the hosts file.

20 tests, driving the real listener over TCP with a fake browser that
actually fetches the redirect -- injecting a fabricated callback would skip
the parsing, path filtering and response writing that can break. Mostly
negative, because the loopback port is reachable by every local process: a
response with the wrong state is rejected *and* never reaches the token
endpoint, metadata declaring an issuer other than its own authority is
rejected (RFC 8414 3.3, without which a mix-up attack works), a provider
offering only 'plain' is fatal rather than a silent downgrade, and the
verifier sent is checked against the challenge advertised so PKCE is not
theatre that only fails in production.

Two bugs caught by writing the tests: the authorize URL builder dropped
client_id entirely after a refactor, and CancellationTokenSource.CancelAfter
has no TimeProvider overload -- so the browser timeout is now constructed
with the clock and a test can advance it instead of waiting five minutes.
2026-07-28 21:13:35 +02:00

93 lines
3.6 KiB
C#

using System.Collections.Specialized;
using System.Web;
namespace DodoSSH.Client.Auth.Tests;
/// <summary>
/// Stands in for the system browser, and actually fetches the loopback redirect.
/// </summary>
/// <remarks>
/// <para>
/// Genuinely drives the client's own <see cref="LoopbackCallbackListener"/> over TCP rather than
/// injecting a callback result. The listener's request parsing, path filtering and response writing
/// are all part of what could break, and none of it is exercised by handing the flow a fabricated
/// code.
/// </para>
/// <para>
/// The redirect is fetched on a background task, not awaited inside <see cref="OpenAsync"/>. The
/// client awaits <c>OpenAsync</c> before it begins accepting, so fetching inline would deadlock: the
/// browser would be waiting for a response that only arrives once the client starts listening.
/// </para>
/// </remarks>
internal sealed class FakeBrowser : IBrowserLauncher
{
private readonly Func<NameValueCollection, IReadOnlyDictionary<string, string>>? buildCallback;
/// <param name="buildCallback">
/// Produces the callback query parameters from the authorization request's parameters. Defaults
/// to a successful code response echoing the state back.
/// </param>
internal FakeBrowser(
Func<NameValueCollection, IReadOnlyDictionary<string, string>>? buildCallback = null) =>
this.buildCallback = buildCallback;
/// <summary>The authorization URL the client asked to open.</summary>
internal Uri? OpenedUrl { get; private set; }
/// <summary>The authorization request's query parameters.</summary>
internal NameValueCollection AuthorizeParameters =>
HttpUtility.ParseQueryString(OpenedUrl?.Query ?? string.Empty);
/// <summary>The background fetch, so a test can surface its failures.</summary>
internal Task? CallbackDelivery { get; private set; }
/// <inheritdoc />
public Task OpenAsync(Uri url, CancellationToken cancellationToken)
{
OpenedUrl = url;
var parameters = HttpUtility.ParseQueryString(url.Query);
var redirectUri = parameters["redirect_uri"]
?? throw new InvalidOperationException("The authorization URL carried no redirect_uri.");
var callback = buildCallback is null
? new Dictionary<string, string>(StringComparer.Ordinal)
{
["code"] = "authorization-code",
["state"] = parameters["state"] ?? string.Empty,
}
: buildCallback(parameters);
CallbackDelivery = Task.Run(() => FetchAsync(redirectUri, callback), cancellationToken);
return Task.CompletedTask;
}
private static async Task FetchAsync(
string redirectUri,
IReadOnlyDictionary<string, string> callback)
{
var query = string.Join(
'&',
callback.Select(p =>
$"{Uri.EscapeDataString(p.Key)}={Uri.EscapeDataString(p.Value)}"));
using var client = new HttpClient();
// A real browser asks for this first. The listener must ignore it rather than treating it as
// the callback, so it is part of the flow under test.
try
{
using var favicon = await client.GetAsync(new Uri($"{redirectUri}/../favicon.ico"));
}
catch (HttpRequestException)
{
// The listener closes the connection after answering; a transport-level failure here is
// not what the test is about.
}
using var response = await client.GetAsync(new Uri($"{redirectUri}?{query}"));
_ = await response.Content.ReadAsStringAsync();
}
}