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())); } }