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;
/// An identity provider stub: discovery and a token endpoint.
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);
}
/// The provider's base URL, which is also its issuer.
internal Uri Authority { get; }
/// Requests the stub received, so tests can assert on what was sent.
internal IReadOnlyList Requests => server.LogEntries.ToList();
/// Stubs a successful token response.
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()));
}
/// Stubs an error from the token endpoint.
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()));
}
/// The form body the token endpoint last received, parsed.
internal IReadOnlyDictionary 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(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;
}
///
public void Dispose()
{
server.Stop();
server.Dispose();
}
///
/// Builds a JWT-shaped ID token with an unverifiable signature.
///
///
/// 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.
///
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()));
}
}