Files
DodoSSH/tests/DodoSSH.Client.Auth.Tests/StubProvider.cs
T
jaap-jan f7c5096bc6 Keep the stub servers on loopback
Running the tests raised a Windows Firewall prompt, and raised it again from
every worktree. WireMockServer.Start() with no settings listens on 0.0.0.0
and [::], and the prompt is keyed to the binary that opened the socket โ€” so
each test executable asks once per bin path, which a new worktree or a switch
between Debug and Release makes new again. The three suites that hold a
firewall rule on this machine are exactly the three that use WireMock; every
other listener in the repository already binds 127.0.0.1.

The stubs now say so explicitly. Port 0 is still WireMock's own free-port
search and still comes back on server.Url, which is what each stub builds its
base URL from, so the authority the API validates against and the issuer its
tokens claim follow the binding rather than being pinned to a host name.

Sampling the listening sockets of a full DodoSSH.Api.Tests run afterwards
finds one, 127.0.0.1, where there were previously three.
2026-07-31 11:06:46 +02:00

180 lines
6.3 KiB
C#

using System.Buffers.Text;
using System.Text;
using System.Text.Json.Nodes;
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
using WireMock.Settings;
namespace DodoSSH.Client.Auth.Tests;
/// <summary>An identity provider stub: discovery and a token endpoint.</summary>
internal sealed class StubProvider : IDisposable
{
private readonly WireMockServer server;
internal StubProvider(
bool advertiseS256 = true,
string? issuerOverride = null)
{
// Loopback explicitly: WireMock's default listens on every interface, which makes Windows Firewall
// prompt the first time each test executable runs โ€” per binary path, so a new worktree or
// configuration asks again. Port 0 still picks a free port and reports it on server.Url.
server = WireMockServer.Start(new WireMockServerSettings { Urls = ["http://127.0.0.1:0"] });
Authority = new Uri(server.Url!.TrimEnd('/'), UriKind.Absolute);
StubDiscovery(advertiseS256, issuerOverride);
}
/// <summary>The provider's base URL, which is also its issuer.</summary>
internal Uri Authority { get; }
/// <summary>Requests the stub received, so tests can assert on what was sent.</summary>
internal IReadOnlyList<WireMock.Logging.ILogEntry> Requests => server.LogEntries.ToList();
/// <summary>Stubs a successful token response.</summary>
internal void StubTokenResponse(
string accessToken = "access-token",
string? refreshToken = "refresh-token",
string? idTokenNonce = null,
bool includeIdToken = false,
int? expiresInSeconds = 3600)
{
var body = new JsonObject
{
["access_token"] = accessToken,
["token_type"] = "Bearer",
};
if (expiresInSeconds is { } seconds)
{
body["expires_in"] = seconds;
}
if (refreshToken is not null)
{
body["refresh_token"] = refreshToken;
}
if (includeIdToken)
{
body["id_token"] = BuildIdToken(idTokenNonce);
}
server
.Given(Request.Create().WithPath("/connect/token").UsingPost())
.RespondWith(Response.Create()
.WithStatusCode(200)
.WithHeader("Content-Type", "application/json")
.WithBody(body.ToJsonString()));
}
/// <summary>Stubs an error from the token endpoint.</summary>
internal void StubTokenError(int statusCode, string error, string description)
{
var body = new JsonObject
{
["error"] = error,
["error_description"] = description,
};
server
.Given(Request.Create().WithPath("/connect/token").UsingPost())
.RespondWith(Response.Create()
.WithStatusCode(statusCode)
.WithHeader("Content-Type", "application/json")
.WithBody(body.ToJsonString()));
}
/// <summary>The form body the token endpoint last received, parsed.</summary>
internal IReadOnlyDictionary<string, string> LastTokenRequestForm()
{
var entries = server.LogEntries
.Where(e => e.RequestMessage?.Path?
.EndsWith("/connect/token", StringComparison.Ordinal) == true)
.ToList();
if (entries.Count == 0)
{
throw new InvalidOperationException("The token endpoint was never called.");
}
var body = entries[^1].RequestMessage?.Body ?? string.Empty;
var form = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var pair in body.Split('&', StringSplitOptions.RemoveEmptyEntries))
{
var separator = pair.IndexOf('=', StringComparison.Ordinal);
if (separator < 0)
{
continue;
}
form[Uri.UnescapeDataString(pair[..separator])] =
Uri.UnescapeDataString(pair[(separator + 1)..].Replace('+', ' '));
}
return form;
}
/// <inheritdoc />
public void Dispose()
{
server.Stop();
server.Dispose();
}
/// <summary>
/// Builds a JWT-shaped ID token with an unverifiable signature.
/// </summary>
/// <remarks>
/// Unsigned on purpose. The client reads the nonce without validating the signature, which OIDC
/// Core ยง3.1.3.7 permits for a token received directly from the token endpoint over TLS โ€” so a
/// real signature here would test nothing the client does.
/// </remarks>
private static string BuildIdToken(string? nonce)
{
var header = new JsonObject { ["alg"] = "RS256", ["typ"] = "JWT" };
var payload = new JsonObject { ["sub"] = "alice", ["iss"] = "https://idp.example" };
if (nonce is not null)
{
payload["nonce"] = nonce;
}
return string.Join('.',
Base64Url.EncodeToString(Encoding.UTF8.GetBytes(header.ToJsonString())),
Base64Url.EncodeToString(Encoding.UTF8.GetBytes(payload.ToJsonString())),
Base64Url.EncodeToString("not-a-real-signature"u8));
}
private void StubDiscovery(bool advertiseS256, string? issuerOverride)
{
var document = new JsonObject
{
["issuer"] = issuerOverride ?? Authority.AbsoluteUri.TrimEnd('/'),
["authorization_endpoint"] = $"{Authority.AbsoluteUri.TrimEnd('/')}/connect/authorize",
["token_endpoint"] = $"{Authority.AbsoluteUri.TrimEnd('/')}/connect/token",
["jwks_uri"] = $"{Authority.AbsoluteUri.TrimEnd('/')}/.well-known/jwks.json",
["response_types_supported"] = new JsonArray("code"),
["id_token_signing_alg_values_supported"] = new JsonArray("RS256"),
};
if (advertiseS256)
{
document["code_challenge_methods_supported"] = new JsonArray("S256");
}
else
{
document["code_challenge_methods_supported"] = new JsonArray("plain");
}
server
.Given(Request.Create().WithPath("/.well-known/openid-configuration").UsingGet())
.RespondWith(Response.Create()
.WithStatusCode(200)
.WithHeader("Content-Type", "application/json")
.WithBody(document.ToJsonString()));
}
}