diff --git a/DodoSSH.slnx b/DodoSSH.slnx index d6ccf6f..503db9e 100644 --- a/DodoSSH.slnx +++ b/DodoSSH.slnx @@ -16,11 +16,13 @@ + + diff --git a/docs/platform-flags.md b/docs/platform-flags.md index abeab87..5b0d6f7 100644 --- a/docs/platform-flags.md +++ b/docs/platform-flags.md @@ -66,6 +66,13 @@ for Windows/macOS/AppImage; Flatpak and deb/rpm defer updates to the package man **Linux ships AppImage and Flatpak first**, specifically so the WebKit runtime is bundled rather than assumed present on the user's machine. +**Opening the system browser depends on the platform handler.** `SystemBrowserLauncher` uses +`UseShellExecute`, which delegates to `ShellExecute` on Windows, `open` on macOS and `xdg-open` on +Linux. *Unverified off Windows:* `xdg-open` comes from `xdg-utils`, which is not guaranteed on a +minimal desktop or inside a Flatpak sandbox — where the portal is the correct route instead. If +sign-in silently does nothing on Linux, this is the first thing to check. `IBrowserLauncher` exists +so a platform-specific opener can be substituted without touching the flow. + ## Build and CI **Integration tests need a Docker daemon** (Testcontainers). They run on `ubuntu-latest` in CI. diff --git a/src/DodoSSH.Client.Auth/BrowserLauncher.cs b/src/DodoSSH.Client.Auth/BrowserLauncher.cs new file mode 100644 index 0000000..a1ac2bf --- /dev/null +++ b/src/DodoSSH.Client.Auth/BrowserLauncher.cs @@ -0,0 +1,56 @@ +using System.Diagnostics; + +namespace DodoSSH.Client.Auth; + +/// Opens a URL in the user's browser. +/// +/// An interface so the sign-in flow can be driven end to end in tests without a browser, and so a +/// packaged build can substitute a platform-specific opener if the default proves unreliable. +/// +public interface IBrowserLauncher +{ + /// Opens the authorization URL. + Task OpenAsync(Uri url, CancellationToken cancellationToken); +} + +/// +/// Opens the system browser. +/// +/// +/// +/// The system browser, never an embedded one. RFC 8252 §8.12: an embedded user-agent lets +/// the application read the user's credentials as they are typed, and denies the user any way to see +/// the real address bar or inspect the provider's certificate. It also cannot reuse an existing +/// single-sign-on session. +/// +/// +/// UseShellExecute delegates to the platform handler — ShellExecute on Windows, +/// open on macOS, xdg-open on Linux. The Linux path depends on xdg-utils being +/// installed, which is not guaranteed on a minimal desktop; see docs/platform-flags.md. +/// +/// +public sealed class SystemBrowserLauncher : IBrowserLauncher +{ + /// + public Task OpenAsync(Uri url, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(url); + cancellationToken.ThrowIfCancellationRequested(); + + // Only ever an authorization URL, but asserted rather than assumed: UseShellExecute on a + // file path would launch it, so a scheme check is the difference between opening a page and + // executing something. + if (!string.Equals(url.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal) + && !string.Equals(url.Scheme, Uri.UriSchemeHttp, StringComparison.Ordinal)) + { + throw new ArgumentException($"Refusing to open a '{url.Scheme}' URL.", nameof(url)); + } + + using var process = Process.Start(new ProcessStartInfo(url.AbsoluteUri) + { + UseShellExecute = true, + }); + + return Task.CompletedTask; + } +} diff --git a/src/DodoSSH.Client.Auth/DodoSSH.Client.Auth.csproj b/src/DodoSSH.Client.Auth/DodoSSH.Client.Auth.csproj new file mode 100644 index 0000000..a56394f --- /dev/null +++ b/src/DodoSSH.Client.Auth/DodoSSH.Client.Auth.csproj @@ -0,0 +1,16 @@ + + + + + + + + + diff --git a/src/DodoSSH.Client.Auth/LoopbackCallbackListener.cs b/src/DodoSSH.Client.Auth/LoopbackCallbackListener.cs new file mode 100644 index 0000000..4536dc7 --- /dev/null +++ b/src/DodoSSH.Client.Auth/LoopbackCallbackListener.cs @@ -0,0 +1,236 @@ +using System.Globalization; +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace DodoSSH.Client.Auth; + +/// The query parameters an authorization callback delivered. +/// Every query parameter, decoded. +public sealed record CallbackResult(IReadOnlyDictionary Parameters) +{ + /// The authorization code, when the callback carried one. + public string? Code => Parameters.GetValueOrDefault("code"); + + /// The state value echoed back, for the caller to compare. + public string? State => Parameters.GetValueOrDefault("state"); + + /// The OAuth error code, when the provider refused. + public string? Error => Parameters.GetValueOrDefault("error"); + + /// The provider's human-readable error description. + public string? ErrorDescription => Parameters.GetValueOrDefault("error_description"); +} + +/// +/// A single-use HTTP listener on an ephemeral loopback port, for the authorization redirect. +/// +/// +/// +/// RFC 8252 §7.3: a native client receives the redirect on a loopback interface, using the system +/// browser rather than an embedded one so the user can see the real address bar and the provider's +/// own certificate. A custom URI scheme is the alternative and is worse — any application on the +/// machine can claim a scheme, whereas only one process can hold a port. +/// +/// +/// Raw rather than HttpListener, for three reasons: an ephemeral +/// port can be bound and read atomically instead of picking a port and hoping it is still free a +/// moment later; there is no HTTP.SYS URL-ACL question on Windows; and the entire surface is one +/// request line, which is easier to reason about than a general-purpose HTTP server. +/// +/// +/// The literal 127.0.0.1 is used rather than localhost. RFC 8252 §8.3 permits either, +/// but the name resolves through the hosts file and DNS, and the address does not. +/// +/// +public sealed class LoopbackCallbackListener : IDisposable +{ + /// Longest request line and header block accepted, as a denial-of-service bound. + private const int MaximumRequestBytes = 16 * 1024; + + private readonly TcpListener listener; + private bool disposed; + + /// Starts listening on a free loopback port. + /// The redirect path, including its leading slash. + public LoopbackCallbackListener(string path = "/callback") + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + if (!path.StartsWith('/')) + { + throw new ArgumentException("The redirect path must begin with '/'.", nameof(path)); + } + + Path = path; + + // Port 0 lets the OS pick, and the bound port is readable immediately — so there is no + // window in which another process could take the port between choosing and binding it. + listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + + Port = ((IPEndPoint)listener.LocalEndpoint).Port; + } + + /// The redirect path this listener answers on. + public string Path { get; } + + /// The port the OS assigned. + public int Port { get; } + + /// The exact redirect URI to send in the authorization request. + /// + /// The token endpoint compares this byte-for-byte against what it saw at authorization time, so + /// it must be built once and reused rather than reconstructed. + /// + public Uri RedirectUri => new( + string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{Port}{Path}"), + UriKind.Absolute); + + /// + /// Waits for the browser to arrive at the redirect. + /// + /// + /// Requests to any other path are answered 404 and ignored rather than ending the wait. A + /// browser will happily ask for /favicon.ico first, and treating that as the callback + /// would abort every sign-in. + /// + /// The page shown in the browser once the code is captured. + /// Cancels the wait — a user closing the sign-in dialog. + public async Task WaitForCallbackAsync( + string completionHtml, + CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(disposed, this); + + while (true) + { + using var client = await listener + .AcceptTcpClientAsync(cancellationToken) + .ConfigureAwait(false); + + var stream = client.GetStream(); + await using var streamScope = stream.ConfigureAwait(false); + + var target = await ReadRequestTargetAsync(stream, cancellationToken).ConfigureAwait(false); + if (target is null) + { + continue; + } + + // Resolved against a fixed base purely to reuse the query parser; the authority is + // never used. + var requested = new Uri(new Uri("http://127.0.0.1/", UriKind.Absolute), target); + + if (!string.Equals(requested.AbsolutePath, Path, StringComparison.Ordinal)) + { + await RespondAsync(stream, "404 Not Found", "

Not found

", cancellationToken) + .ConfigureAwait(false); + continue; + } + + await RespondAsync(stream, "200 OK", completionHtml, cancellationToken).ConfigureAwait(false); + + return new CallbackResult(ParseQuery(requested.Query)); + } + } + + /// + public void Dispose() + { + if (disposed) + { + return; + } + + disposed = true; + listener.Dispose(); + } + + /// Reads the request target from the request line, then drains the headers. + /// The target, or if the request was not a usable GET. + private static async Task ReadRequestTargetAsync( + Stream stream, + CancellationToken cancellationToken) + { + var buffer = new byte[MaximumRequestBytes]; + var count = 0; + + // Read until the end of the header block. The body is irrelevant: a redirect is a GET. + while (count < buffer.Length) + { + var read = await stream + .ReadAsync(buffer.AsMemory(count), cancellationToken) + .ConfigureAwait(false); + + if (read == 0) + { + break; + } + + count += read; + + if (Encoding.ASCII.GetString(buffer, 0, count).Contains("\r\n\r\n", StringComparison.Ordinal)) + { + break; + } + } + + var text = Encoding.ASCII.GetString(buffer, 0, count); + var firstLine = text.Split("\r\n", StringSplitOptions.None)[0]; + var parts = firstLine.Split(' '); + + return parts.Length >= 2 && string.Equals(parts[0], "GET", StringComparison.Ordinal) + ? parts[1] + : null; + } + + private static async Task RespondAsync( + Stream stream, + string status, + string html, + CancellationToken cancellationToken) + { + var body = Encoding.UTF8.GetBytes(html); + + // Plain concatenation with an explicit invariant conversion. string.Create's interpolated + // overload takes a handler by ref, so a concatenation of interpolated pieces does not bind + // to it — and the header must stay byte-exact regardless of culture. + var header = + "HTTP/1.1 " + status + "\r\n" + + "Content-Type: text/html; charset=utf-8\r\n" + + "Content-Length: " + body.Length.ToString(CultureInfo.InvariantCulture) + "\r\n" + + // The page has served its purpose the moment it renders; caching it would leave the + // authorization code's query string in the browser's cache. + + "Cache-Control: no-store\r\n" + + "Connection: close\r\n\r\n"; + + await stream.WriteAsync(Encoding.ASCII.GetBytes(header), cancellationToken).ConfigureAwait(false); + await stream.WriteAsync(body, cancellationToken).ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + private static Dictionary ParseQuery(string query) + { + var parameters = new Dictionary(StringComparer.Ordinal); + + foreach (var pair in query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var separator = pair.IndexOf('=', StringComparison.Ordinal); + + if (separator < 0) + { + parameters[Uri.UnescapeDataString(pair)] = string.Empty; + continue; + } + + var name = Uri.UnescapeDataString(pair[..separator]); + var value = Uri.UnescapeDataString(pair[(separator + 1)..].Replace('+', ' ')); + + parameters[name] = value; + } + + return parameters; + } +} diff --git a/src/DodoSSH.Client.Auth/OidcClient.cs b/src/DodoSSH.Client.Auth/OidcClient.cs new file mode 100644 index 0000000..eb928ea --- /dev/null +++ b/src/DodoSSH.Client.Auth/OidcClient.cs @@ -0,0 +1,407 @@ +using System.Buffers.Text; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace DodoSSH.Client.Auth; + +/// +/// Authorization Code with PKCE on a loopback redirect, for a native public client. +/// +/// +/// +/// Hand-rolled rather than delegating to an OIDC library, for one specific reason: the +/// identity-provider key binding needs the nonce set to an exact value — the hash of a key +/// statement — and libraries generate and validate their own nonce as an internal detail. Fighting +/// that is worse than owning a flow the RFCs specify completely. +/// +/// +/// State and PKCE are per-request and never reused. The response is rejected unless the state +/// matches, which is what stops an attacker feeding the listener a code of their own — the loopback +/// port is reachable by any local process. +/// +/// +public sealed class OidcClient( + HttpClient http, + IBrowserLauncher browser, + TimeProvider clock, + OidcClientOptions options) +{ + private readonly OidcDiscoveryClient discovery = new(http); + + /// Runs an interactive sign-in and returns the resulting tokens. + public async Task SignInAsync(CancellationToken cancellationToken) + { + var metadata = await GetMetadataAsync(cancellationToken).ConfigureAwait(false); + + var (code, redirectUri, pkce) = await AuthorizeAsync( + metadata, + options.Scopes, + extraParameters: null, + cancellationToken) + .ConfigureAwait(false); + + return await ExchangeAsync( + metadata, + new Dictionary(StringComparer.Ordinal) + { + ["grant_type"] = "authorization_code", + ["code"] = code, + ["redirect_uri"] = redirectUri.AbsoluteUri, + ["client_id"] = options.ClientId, + ["code_verifier"] = pkce.CodeVerifier, + }, + cancellationToken) + .ConfigureAwait(false); + } + + /// Exchanges a refresh token for a fresh access token. + /// + /// A provider may rotate the refresh token, so the caller must persist whatever comes back + /// rather than keeping the one it sent. Treating rotation as optional is how a client ends up + /// permanently signed out after one refresh. + /// + public async Task RefreshAsync(string refreshToken, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(refreshToken); + + var metadata = await GetMetadataAsync(cancellationToken).ConfigureAwait(false); + + var refreshed = await ExchangeAsync( + metadata, + new Dictionary(StringComparer.Ordinal) + { + ["grant_type"] = "refresh_token", + ["refresh_token"] = refreshToken, + ["client_id"] = options.ClientId, + }, + cancellationToken) + .ConfigureAwait(false); + + // Providers that do not rotate omit the field entirely; carry the old one forward so the + // caller can persist one value unconditionally. + return refreshed.RefreshToken is null + ? refreshed with { RefreshToken = refreshToken } + : refreshed; + } + + /// + /// Runs a second authorization whose sole purpose is to have the provider sign over a set of + /// public keys. + /// + /// + /// + /// This is the primary public-key trust anchor. The is the hash + /// of the key statement (docs/crypto.md §7.1), so the ID token that comes back is the provider's + /// signature over exactly those keys. The DodoSSH server cannot mint that signature, so it cannot + /// fabricate a key for a user who never enrolled — the attack that would otherwise let an + /// operator read every vault by publishing its own key as yours. See ADR 0001. + /// + /// + /// prompt=login forces a fresh authentication rather than reusing an existing session, so + /// the assertion attests to a user present at this moment rather than to a session opened at some + /// unknown earlier time. + /// + /// + /// Only openid is requested. A second refresh token here would be one more long-lived + /// credential to store for no benefit. + /// + /// + /// + /// From KeyStatementCodec.ComputeNonce. Must be the statement being enrolled. + /// + /// Cancels the wait for the browser. + /// The ID token to hand to POST /api/v1/me/enrollment. + public async Task AuthorizeKeyBindingAsync( + string bindingNonce, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(bindingNonce); + + var metadata = await GetMetadataAsync(cancellationToken).ConfigureAwait(false); + + var (code, redirectUri, pkce) = await AuthorizeAsync( + metadata, + ["openid"], + new Dictionary(StringComparer.Ordinal) + { + ["nonce"] = bindingNonce, + ["prompt"] = "login", + }, + cancellationToken) + .ConfigureAwait(false); + + var tokens = await ExchangeAsync( + metadata, + new Dictionary(StringComparer.Ordinal) + { + ["grant_type"] = "authorization_code", + ["code"] = code, + ["redirect_uri"] = redirectUri.AbsoluteUri, + ["client_id"] = options.ClientId, + ["code_verifier"] = pkce.CodeVerifier, + }, + cancellationToken) + .ConfigureAwait(false); + + if (tokens.IdToken is null) + { + throw new OidcException( + "The provider returned no ID token, so nothing binds the keys. Check that the " + + "'openid' scope is permitted for this client."); + } + + var returned = ReadNonceClaim(tokens.IdToken); + + if (!string.Equals(returned, bindingNonce, StringComparison.Ordinal)) + { + // A token that does not carry our nonce binds some other statement. Enrolling it would + // store evidence that verifies against keys we are not publishing. + throw new OidcException( + "The ID token's nonce is not the key statement's hash, so it does not bind these keys."); + } + + return tokens.IdToken; + } + + private Task GetMetadataAsync(CancellationToken cancellationToken) => + discovery.GetAsync(options.Authority, options.RequireHttpsMetadata, cancellationToken); + + /// Opens the browser and waits for a matching callback. + private async Task<(string Code, Uri RedirectUri, PkcePair Pkce)> AuthorizeAsync( + OidcProviderMetadata metadata, + IReadOnlyList scopes, + IReadOnlyDictionary? extraParameters, + CancellationToken cancellationToken) + { + if (metadata.CodeChallengeMethodsSupported.Count > 0 + && !metadata.CodeChallengeMethodsSupported.Contains(PkcePair.Method, StringComparer.Ordinal)) + { + // Continuing without PKCE is not an option for a public client, so this is fatal rather + // than a downgrade. + throw new OidcException( + $"The provider does not advertise the {PkcePair.Method} code challenge method."); + } + + var pkce = PkcePair.Create(); + var state = Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(32)); + + using var listener = new LoopbackCallbackListener(options.RedirectPath); + var redirectUri = listener.RedirectUri; + + var authorizeUri = BuildAuthorizeUri( + metadata, options.ClientId, scopes, pkce, state, redirectUri, extraParameters); + + await browser.OpenAsync(authorizeUri, cancellationToken).ConfigureAwait(false); + + // Constructed with the TimeProvider rather than CancelAfter, so a test can advance a fake + // clock instead of waiting out a five-minute browser timeout. + using var timeout = new CancellationTokenSource(options.BrowserTimeout, clock); + using var linked = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + timeout.Token); + + CallbackResult callback; + try + { + callback = await listener + .WaitForCallbackAsync(options.CompletionHtml, linked.Token) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw new OidcException( + $"No authorization response arrived within {options.BrowserTimeout}."); + } + + return (ExtractCode(callback, state), redirectUri, pkce); + } + + /// Checks a callback and returns its authorization code. + private static string ExtractCode(CallbackResult callback, string expectedState) + { + if (callback.Error is { } error) + { + throw new OidcException( + $"The provider refused authorization: {callback.ErrorDescription ?? error}.", + error); + } + + // Checked before the code is touched. Constant-time comparison is unnecessary — state is not + // a secret being guessed byte by byte — but it must be exact and ordinal. + if (!string.Equals(callback.State, expectedState, StringComparison.Ordinal)) + { + throw new OidcException( + "The authorization response carried the wrong state and was discarded. Any local " + + "process can reach the loopback port, so an unmatched response is treated as hostile."); + } + + if (string.IsNullOrEmpty(callback.Code)) + { + throw new OidcException("The authorization response carried no code."); + } + + return callback.Code; + } + + /// + /// Extra parameters are applied last and may override the defaults. That is what lets the key + /// binding flow supply its own nonce and prompt, which is the reason this is + /// hand-rolled at all. + /// + private static Uri BuildAuthorizeUri( + OidcProviderMetadata metadata, + string clientId, + IReadOnlyList scopes, + PkcePair pkce, + string state, + Uri redirectUri, + IReadOnlyDictionary? extraParameters) + { + var parameters = new Dictionary(StringComparer.Ordinal) + { + ["response_type"] = "code", + ["client_id"] = clientId, + ["redirect_uri"] = redirectUri.AbsoluteUri, + ["scope"] = string.Join(' ', scopes), + ["state"] = state, + ["code_challenge"] = pkce.CodeChallenge, + ["code_challenge_method"] = PkcePair.Method, + }; + + if (extraParameters is not null) + { + foreach (var (name, value) in extraParameters) + { + parameters[name] = value; + } + } + + // The endpoint may already carry a query — Keycloak's does not, but some providers pin a + // tenant or an audience there, and clobbering it would break them. + var uri = new StringBuilder(metadata.AuthorizationEndpoint.AbsoluteUri); + uri.Append(metadata.AuthorizationEndpoint.Query.Length > 0 ? '&' : '?'); + + var first = true; + foreach (var (name, value) in parameters) + { + if (!first) + { + uri.Append('&'); + } + + first = false; + uri.Append(Uri.EscapeDataString(name)).Append('=').Append(Uri.EscapeDataString(value)); + } + + return new Uri(uri.ToString(), UriKind.Absolute); + } + + private async Task ExchangeAsync( + OidcProviderMetadata metadata, + Dictionary form, + CancellationToken cancellationToken) + { + using var content = new FormUrlEncodedContent(form); + using var response = await http + .PostAsync(metadata.TokenEndpoint, content, cancellationToken) + .ConfigureAwait(false); + + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + + using var document = ParseOrThrow(body, response.StatusCode); + var root = document.RootElement; + + if (!response.IsSuccessStatusCode) + { + var error = ReadString(root, "error") ?? "unknown_error"; + + throw new OidcException( + $"The token endpoint returned {(int)response.StatusCode}: " + + $"{ReadString(root, "error_description") ?? error}.", + error); + } + + var accessToken = ReadString(root, "access_token") + ?? throw new OidcException("The token response contained no access token."); + + // Absent expires_in is legal and means unspecified. Treating that as "never expires" would + // hand the caller a token it never refreshes; a short assumed lifetime degrades to an extra + // refresh instead. + var lifetime = root.TryGetProperty("expires_in", out var expires) + && expires.ValueKind == JsonValueKind.Number + && expires.TryGetInt64(out var seconds) + ? TimeSpan.FromSeconds(seconds) + : TimeSpan.FromMinutes(5); + + return new TokenSet( + AccessToken: accessToken, + RefreshToken: ReadString(root, "refresh_token"), + IdToken: ReadString(root, "id_token"), + ExpiresAtUtc: clock.GetUtcNow() + lifetime, + Scope: ReadString(root, "scope")); + } + + private static JsonDocument ParseOrThrow(string body, System.Net.HttpStatusCode status) + { + try + { + return JsonDocument.Parse(body); + } + catch (JsonException exception) + { + throw new OidcException( + $"The token endpoint returned {(int)status} with a body that is not JSON: " + + exception.Message); + } + } + + private static string? ReadString(JsonElement root, string name) => + root.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.String + ? property.GetString() + : null; + + /// + /// Reads the nonce claim out of an ID token without verifying its signature. + /// + /// + /// Sanctioned by OpenID Connect Core §3.1.3.7: when an ID token is received by direct + /// communication with the token endpoint, TLS server authentication may stand in for checking the + /// token signature. It is the channel that establishes who sent this, so the nonce check here is + /// about detecting a provider bug or a mixed-up response, not about trusting an unsigned token. + /// + /// This reasoning does not extend to another user's key binding, which arrives through the + /// DodoSSH server and must be verified against the provider's JWKS fetched directly. That is the + /// directory work in M3. + /// + /// + private static string? ReadNonceClaim(string idToken) + { + var segments = idToken.Split('.'); + if (segments.Length < 2) + { + throw new OidcException("The ID token is not a JWT."); + } + + byte[] payload; + try + { + payload = Base64Url.DecodeFromChars(segments[1]); + } + catch (FormatException) + { + throw new OidcException("The ID token's payload is not valid base64url."); + } + + try + { + using var document = JsonDocument.Parse(payload); + return ReadString(document.RootElement, "nonce"); + } + catch (JsonException) + { + throw new OidcException("The ID token's payload is not JSON."); + } + } +} diff --git a/src/DodoSSH.Client.Auth/OidcClientOptions.cs b/src/DodoSSH.Client.Auth/OidcClientOptions.cs new file mode 100644 index 0000000..e0a911f --- /dev/null +++ b/src/DodoSSH.Client.Auth/OidcClientOptions.cs @@ -0,0 +1,49 @@ +namespace DodoSSH.Client.Auth; + +/// +/// Everything needed to talk to one identity provider. +/// +/// +/// Populated from the DodoSSH server's /.well-known/dodossh-configuration, which is the whole +/// onboarding story: the user types one server URL and the client discovers the authority, the client +/// id and the scopes from it. +/// +public sealed class OidcClientOptions +{ + /// The provider's issuer URL. + public required Uri Authority { get; init; } + + /// The public client identifier registered for the desktop app. + public required string ClientId { get; init; } + + /// + /// Scopes requested at sign-in. + /// + /// + /// offline_access is what yields a refresh token, and without one the user re-authenticates + /// through the browser every time the access token expires. + /// + public IReadOnlyList Scopes { get; init; } = ["openid", "profile", "email", "offline_access"]; + + /// Path the loopback listener answers the redirect on. + public string RedirectPath { get; init; } = "/callback"; + + /// Whether provider metadata must be served over HTTPS. Only false for local development. + public bool RequireHttpsMetadata { get; init; } = true; + + /// How long to wait for the user to finish in the browser. + /// + /// Generous, because the user may have to find a password manager, complete a second factor, or + /// approve a push notification on another device. + /// + public TimeSpan BrowserTimeout { get; init; } = TimeSpan.FromMinutes(5); + + /// Page shown in the browser once the callback is captured. + public string CompletionHtml { get; init; } = + """ + DodoSSH + +

Signed in

You can close this tab and return to DodoSSH.

+ + """; +} diff --git a/src/DodoSSH.Client.Auth/OidcProviderMetadata.cs b/src/DodoSSH.Client.Auth/OidcProviderMetadata.cs new file mode 100644 index 0000000..84dbc5a --- /dev/null +++ b/src/DodoSSH.Client.Auth/OidcProviderMetadata.cs @@ -0,0 +1,145 @@ +using System.Text.Json; + +namespace DodoSSH.Client.Auth; + +/// Raised when an OIDC exchange fails in a way the caller must handle. +public sealed class OidcException(string message, string? errorCode = null) : Exception(message) +{ + /// The provider's OAuth error code, when it supplied one. + public string? ErrorCode { get; } = errorCode; +} + +/// The parts of an OIDC discovery document this client uses. +/// The provider's own idea of its issuer identifier. +/// Where the browser is sent. +/// Where codes and refresh tokens are redeemed. +/// +/// Where signing keys are published. Not used to validate tokens received directly from the token +/// endpoint, but required in M3 to verify other users' key bindings — fetched from here, never +/// proxied through the DodoSSH server, which is the entire point of the binding. See ADR 0001. +/// +/// Advertised PKCE methods. +public sealed record OidcProviderMetadata( + Uri Issuer, + Uri AuthorizationEndpoint, + Uri TokenEndpoint, + Uri? JwksUri, + IReadOnlyList CodeChallengeMethodsSupported); + +/// Fetches and validates an OIDC discovery document. +public sealed class OidcDiscoveryClient(HttpClient http) +{ + private const string DiscoveryPath = ".well-known/openid-configuration"; + + /// + /// Reads the discovery document for an authority. + /// + /// + /// The document's issuer is checked against the authority the URL was built from, per + /// RFC 8414 §3.3. Skipping that check is what enables a mix-up attack: an attacker-controlled + /// authority can serve metadata pointing at a legitimate provider's endpoints, and the client + /// then hands its code to the attacker's token endpoint while believing it is talking to the + /// real one. + /// + public async Task GetAsync( + Uri authority, + bool requireHttps, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(authority); + + if (requireHttps && !string.Equals(authority.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal)) + { + throw new OidcException( + $"The authority '{authority}' is not HTTPS. Plaintext metadata is only ever " + + "acceptable for local development."); + } + + // A trailing slash matters: without it Uri would replace the last path segment, so an + // authority with a realm path would resolve to the wrong document. + var basePath = authority.AbsoluteUri.EndsWith('/') ? authority : new Uri(authority.AbsoluteUri + "/"); + var discoveryUri = new Uri(basePath, DiscoveryPath); + + using var response = await http.GetAsync(discoveryUri, cancellationToken).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + throw new OidcException( + $"Discovery at '{discoveryUri}' returned {(int)response.StatusCode}."); + } + + var content = await response.Content + .ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false); + + await using var contentScope = content.ConfigureAwait(false); + + using var document = await JsonDocument + .ParseAsync(content, cancellationToken: cancellationToken) + .ConfigureAwait(false); + + var metadata = Read(document.RootElement, discoveryUri); + + if (!IssuerMatches(metadata.Issuer, authority)) + { + throw new OidcException( + $"The discovery document at '{discoveryUri}' declares issuer '{metadata.Issuer}', " + + $"which is not the authority '{authority}' it was fetched from."); + } + + return metadata; + } + + private static OidcProviderMetadata Read(JsonElement root, Uri discoveryUri) => + new( + Issuer: RequireUri(root, "issuer", discoveryUri), + AuthorizationEndpoint: RequireUri(root, "authorization_endpoint", discoveryUri), + TokenEndpoint: RequireUri(root, "token_endpoint", discoveryUri), + JwksUri: OptionalUri(root, "jwks_uri"), + CodeChallengeMethodsSupported: ReadStrings(root, "code_challenge_methods_supported")); + + /// + /// Compared after normalising a single trailing slash, because providers are inconsistent about + /// it and a mismatch there is cosmetic rather than an attack. + /// + private static bool IssuerMatches(Uri issuer, Uri authority) => + string.Equals( + issuer.AbsoluteUri.TrimEnd('/'), + authority.AbsoluteUri.TrimEnd('/'), + StringComparison.OrdinalIgnoreCase); + + private static Uri RequireUri(JsonElement root, string name, Uri discoveryUri) + { + var value = OptionalUri(root, name) + ?? throw new OidcException( + $"The discovery document at '{discoveryUri}' has no usable '{name}'."); + + return value; + } + + private static Uri? OptionalUri(JsonElement root, string name) => + root.TryGetProperty(name, out var property) + && property.ValueKind == JsonValueKind.String + && Uri.TryCreate(property.GetString(), UriKind.Absolute, out var uri) + ? uri + : null; + + private static List ReadStrings(JsonElement root, string name) + { + if (!root.TryGetProperty(name, out var property) || property.ValueKind != JsonValueKind.Array) + { + return []; + } + + var values = new List(); + foreach (var element in property.EnumerateArray()) + { + if (element.ValueKind == JsonValueKind.String && element.GetString() is { } value) + { + values.Add(value); + } + } + + return values; + } +} diff --git a/src/DodoSSH.Client.Auth/PkcePair.cs b/src/DodoSSH.Client.Auth/PkcePair.cs new file mode 100644 index 0000000..dcd8397 --- /dev/null +++ b/src/DodoSSH.Client.Auth/PkcePair.cs @@ -0,0 +1,57 @@ +using System.Buffers.Text; +using System.Security.Cryptography; +using System.Text; + +namespace DodoSSH.Client.Auth; + +/// +/// A PKCE code verifier and its S256 challenge. See RFC 7636. +/// +/// +/// +/// A public client cannot keep a secret, so PKCE is what stops an authorization code being +/// redeemed by anyone who intercepts it — on a loopback redirect that means any other local +/// process that manages to receive the callback. The token endpoint only accepts the code +/// alongside the verifier whose hash it saw at authorization time. +/// +/// +/// S256 only. The plain method is still in the RFC and offers no protection +/// whatsoever against an attacker who saw the authorization request. +/// +/// +public sealed class PkcePair +{ + /// The challenge method sent to the authorization endpoint. + public const string Method = "S256"; + + /// + /// Entropy behind the verifier. 32 bytes renders as 43 base64url characters, the RFC's + /// minimum length and comfortably beyond guessing. + /// + private const int VerifierEntropyBytes = 32; + + private PkcePair(string codeVerifier, string codeChallenge) + { + CodeVerifier = codeVerifier; + CodeChallenge = codeChallenge; + } + + /// The secret held until the token exchange. Never leaves the process. + public string CodeVerifier { get; } + + /// The hash sent with the authorization request. + public string CodeChallenge { get; } + + /// Generates a fresh pair. + public static PkcePair Create() + { + // Base64url of random bytes, which satisfies the RFC's unreserved-character set without + // any escaping. Generating characters directly from an alphabet would be one more place to + // get a modulo bias wrong for no benefit. + var verifier = Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(VerifierEntropyBytes)); + + var challenge = Base64Url.EncodeToString(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))); + + return new PkcePair(verifier, challenge); + } +} diff --git a/src/DodoSSH.Client.Auth/TokenSet.cs b/src/DodoSSH.Client.Auth/TokenSet.cs new file mode 100644 index 0000000..a9f3c54 --- /dev/null +++ b/src/DodoSSH.Client.Auth/TokenSet.cs @@ -0,0 +1,38 @@ +namespace DodoSSH.Client.Auth; + +/// What a token exchange returned. +/// +/// The refresh token is the one long-lived credential the client holds, and the only part of this +/// that belongs in the OS keystore. The access token is short-lived and re-obtainable, and the ID +/// token is an assertion rather than a credential. None of them can open the vault: that needs the +/// passphrase, which is never stored anywhere. +/// +/// Bearer token for the DodoSSH API. +/// Refresh token, when offline_access was granted. +/// Identity assertion, when openid was requested. +/// When the access token stops being accepted. +/// Scopes actually granted, which may be narrower than those requested. +public sealed record TokenSet( + string AccessToken, + string? RefreshToken, + string? IdToken, + DateTimeOffset ExpiresAtUtc, + string? Scope) +{ + /// + /// Whether the access token should be refreshed before use. + /// + /// + /// The margin exists because expiry is checked here and enforced by the server after a network + /// round trip. Without it a token that is valid at the moment of the check is rejected by the + /// time it arrives, which surfaces as a random 401 mid-sync. + /// + /// Time source. + /// How far ahead to consider the token already expired. + public bool NeedsRefresh(TimeProvider clock, TimeSpan? margin = null) + { + ArgumentNullException.ThrowIfNull(clock); + + return clock.GetUtcNow() + (margin ?? TimeSpan.FromSeconds(60)) >= ExpiresAtUtc; + } +} diff --git a/src/DodoSSH.Client.Auth/packages.lock.json b/src/DodoSSH.Client.Auth/packages.lock.json new file mode 100644 index 0000000..722652b --- /dev/null +++ b/src/DodoSSH.Client.Auth/packages.lock.json @@ -0,0 +1,19 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.134, )", + "resolved": "3.0.134", + "contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ==" + }, + "Microsoft.CodeAnalysis.BannedApiAnalyzers": { + "type": "Direct", + "requested": "[5.6.0, )", + "resolved": "5.6.0", + "contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw==" + } + } + } +} \ No newline at end of file diff --git a/tests/DodoSSH.Client.Auth.Tests/DodoSSH.Client.Auth.Tests.csproj b/tests/DodoSSH.Client.Auth.Tests/DodoSSH.Client.Auth.Tests.csproj new file mode 100644 index 0000000..8d5f469 --- /dev/null +++ b/tests/DodoSSH.Client.Auth.Tests/DodoSSH.Client.Auth.Tests.csproj @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + diff --git a/tests/DodoSSH.Client.Auth.Tests/FakeBrowser.cs b/tests/DodoSSH.Client.Auth.Tests/FakeBrowser.cs new file mode 100644 index 0000000..f7a6f28 --- /dev/null +++ b/tests/DodoSSH.Client.Auth.Tests/FakeBrowser.cs @@ -0,0 +1,92 @@ +using System.Collections.Specialized; +using System.Web; + +namespace DodoSSH.Client.Auth.Tests; + +/// +/// Stands in for the system browser, and actually fetches the loopback redirect. +/// +/// +/// +/// Genuinely drives the client's own 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. +/// +/// +/// The redirect is fetched on a background task, not awaited inside . The +/// client awaits OpenAsync 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. +/// +/// +internal sealed class FakeBrowser : IBrowserLauncher +{ + private readonly Func>? buildCallback; + + /// + /// Produces the callback query parameters from the authorization request's parameters. Defaults + /// to a successful code response echoing the state back. + /// + internal FakeBrowser( + Func>? buildCallback = null) => + this.buildCallback = buildCallback; + + /// The authorization URL the client asked to open. + internal Uri? OpenedUrl { get; private set; } + + /// The authorization request's query parameters. + internal NameValueCollection AuthorizeParameters => + HttpUtility.ParseQueryString(OpenedUrl?.Query ?? string.Empty); + + /// The background fetch, so a test can surface its failures. + internal Task? CallbackDelivery { get; private set; } + + /// + 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(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 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(); + } +} diff --git a/tests/DodoSSH.Client.Auth.Tests/OidcClientTests.cs b/tests/DodoSSH.Client.Auth.Tests/OidcClientTests.cs new file mode 100644 index 0000000..850468b --- /dev/null +++ b/tests/DodoSSH.Client.Auth.Tests/OidcClientTests.cs @@ -0,0 +1,406 @@ +using System.Collections.Specialized; +using DodoSSH.Crypto; + +namespace DodoSSH.Client.Auth.Tests; + +/// +/// The authorization code flow end to end, and the rejections that make it safe. +/// +/// +/// A loopback redirect is reachable by every process on the machine, so most of the value here is in +/// the negative cases: a response with the wrong state, a provider whose metadata disagrees with its +/// own authority, an ID token that binds a different key statement. +/// +public sealed class OidcClientTests : IDisposable +{ + private readonly StubProvider provider = new(); + private readonly HttpClient http = new(); + + /// + public void Dispose() + { + http.Dispose(); + provider.Dispose(); + } + + // ---- The happy path ---- + + [Fact] + public async Task SignIn_CompletesTheFlowAndReturnsTokens() + { + provider.StubTokenResponse(); + var browser = new FakeBrowser(); + + var tokens = await CreateClient(browser).SignInAsync(TestContext.Current.CancellationToken); + + tokens.AccessToken.ShouldBe("access-token"); + tokens.RefreshToken.ShouldBe("refresh-token"); + tokens.ExpiresAtUtc.ShouldBeGreaterThan(TimeProvider.System.GetUtcNow()); + + await browser.CallbackDelivery!; + } + + [Fact] + public async Task TheAuthorizationRequest_CarriesEverythingTheFlowNeeds() + { + // The client_id is asserted explicitly: an authorization request without it is rejected by + // the provider with an opaque error, and it is easy to drop while refactoring the builder. + provider.StubTokenResponse(); + var browser = new FakeBrowser(); + + await CreateClient(browser).SignInAsync(TestContext.Current.CancellationToken); + + var parameters = browser.AuthorizeParameters; + + parameters["response_type"].ShouldBe("code"); + parameters["client_id"].ShouldBe("dodossh-desktop"); + parameters["code_challenge_method"].ShouldBe("S256"); + parameters["code_challenge"].ShouldNotBeNullOrWhiteSpace(); + parameters["state"].ShouldNotBeNullOrWhiteSpace(); + parameters["scope"].ShouldBe("openid profile email offline_access"); + parameters["redirect_uri"].ShouldNotBeNull(); + parameters["redirect_uri"]!.ShouldStartWith("http://127.0.0.1:"); + + await browser.CallbackDelivery!; + } + + [Fact] + public async Task TheTokenExchange_SendsTheVerifierAndTheSameRedirectUri() + { + // The token endpoint compares redirect_uri byte-for-byte against what it saw at + // authorization time, so a reconstructed value fails in a way that is hard to diagnose. + provider.StubTokenResponse(); + var browser = new FakeBrowser(); + + await CreateClient(browser).SignInAsync(TestContext.Current.CancellationToken); + + var form = provider.LastTokenRequestForm(); + + form["grant_type"].ShouldBe("authorization_code"); + form["code"].ShouldBe("authorization-code"); + form["client_id"].ShouldBe("dodossh-desktop"); + form["code_verifier"].ShouldNotBeNullOrWhiteSpace(); + form["redirect_uri"].ShouldBe(browser.AuthorizeParameters["redirect_uri"]); + + await browser.CallbackDelivery!; + } + + [Fact] + public async Task TheVerifierSent_MatchesTheChallengeAdvertised() + { + // Otherwise PKCE is theatre: the provider would reject it, but only in production. + provider.StubTokenResponse(); + var browser = new FakeBrowser(); + + await CreateClient(browser).SignInAsync(TestContext.Current.CancellationToken); + + var verifier = provider.LastTokenRequestForm()["code_verifier"]; + var challenge = browser.AuthorizeParameters["code_challenge"]; + + Recompute(verifier).ShouldBe(challenge); + + await browser.CallbackDelivery!; + } + + // ---- Rejections ---- + + [Fact] + public async Task AResponseWithTheWrongState_IsRejected() + { + // The CSRF defence, and on loopback the defence against any local process racing to deliver + // its own code to our listener. + provider.StubTokenResponse(); + + var browser = new FakeBrowser(_ => new Dictionary(StringComparer.Ordinal) + { + ["code"] = "attacker-code", + ["state"] = "not-the-state-we-sent", + }); + + var exception = await Should.ThrowAsync(async () => + await CreateClient(browser).SignInAsync(TestContext.Current.CancellationToken)); + + exception.Message.ShouldContain("state"); + } + + [Fact] + public async Task AResponseWithTheWrongState_NeverReachesTheTokenEndpoint() + { + // A rejection that still redeemed the code would defeat the point. + provider.StubTokenResponse(); + + var browser = new FakeBrowser(_ => new Dictionary(StringComparer.Ordinal) + { + ["code"] = "attacker-code", + ["state"] = "wrong", + }); + + await Should.ThrowAsync(async () => + await CreateClient(browser).SignInAsync(TestContext.Current.CancellationToken)); + + Should.Throw(() => provider.LastTokenRequestForm()); + } + + [Fact] + public async Task AProviderRefusal_SurfacesWithItsErrorCode() + { + var browser = new FakeBrowser(_ => new Dictionary(StringComparer.Ordinal) + { + ["error"] = "access_denied", + ["error_description"] = "The user said no", + }); + + var exception = await Should.ThrowAsync(async () => + await CreateClient(browser).SignInAsync(TestContext.Current.CancellationToken)); + + exception.ErrorCode.ShouldBe("access_denied"); + exception.Message.ShouldContain("The user said no"); + } + + [Fact] + public async Task ACallbackWithNoCode_IsRejected() + { + var browser = new FakeBrowser(parameters => + new Dictionary(StringComparer.Ordinal) + { + ["state"] = parameters["state"] ?? string.Empty, + }); + + await Should.ThrowAsync(async () => + await CreateClient(browser).SignInAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task ATokenEndpointError_Surfaces() + { + provider.StubTokenError(400, "invalid_grant", "Code already redeemed"); + var browser = new FakeBrowser(); + + var exception = await Should.ThrowAsync(async () => + await CreateClient(browser).SignInAsync(TestContext.Current.CancellationToken)); + + exception.ErrorCode.ShouldBe("invalid_grant"); + } + + [Fact] + public async Task AProviderThatDoesNotOfferS256_IsRejected() + { + // Falling back to 'plain' is not an option for a public client, so this is fatal rather than + // a silent downgrade. + using var plainOnly = new StubProvider(advertiseS256: false); + plainOnly.StubTokenResponse(); + + var client = new OidcClient( + http, + new FakeBrowser(), + TimeProvider.System, + OptionsFor(plainOnly.Authority)); + + var exception = await Should.ThrowAsync(async () => + await client.SignInAsync(TestContext.Current.CancellationToken)); + + exception.Message.ShouldContain("S256"); + } + + [Fact] + public async Task AProviderWhoseMetadataDeclaresAnotherIssuer_IsRejected() + { + // RFC 8414 §3.3. Without this check an attacker-controlled authority can serve metadata + // pointing at its own token endpoint, and the client hands over the code believing it is + // talking to the real provider. + using var lying = new StubProvider(issuerOverride: "https://someone-else.example"); + + var client = new OidcClient( + http, + new FakeBrowser(), + TimeProvider.System, + OptionsFor(lying.Authority)); + + var exception = await Should.ThrowAsync(async () => + await client.SignInAsync(TestContext.Current.CancellationToken)); + + exception.Message.ShouldContain("issuer"); + } + + [Fact] + public async Task APlaintextAuthority_IsRejectedWhenHttpsIsRequired() + { + var client = new OidcClient( + http, + new FakeBrowser(), + TimeProvider.System, + new OidcClientOptions + { + Authority = provider.Authority, + ClientId = "dodossh-desktop", + RequireHttpsMetadata = true, + }); + + var exception = await Should.ThrowAsync(async () => + await client.SignInAsync(TestContext.Current.CancellationToken)); + + exception.Message.ShouldContain("HTTPS"); + } + + // ---- Refresh ---- + + [Fact] + public async Task Refresh_ExchangesTheToken() + { + provider.StubTokenResponse(accessToken: "fresh-access", refreshToken: "rotated-refresh"); + + var tokens = await CreateClient(new FakeBrowser()) + .RefreshAsync("old-refresh", TestContext.Current.CancellationToken); + + tokens.AccessToken.ShouldBe("fresh-access"); + tokens.RefreshToken.ShouldBe("rotated-refresh"); + + var form = provider.LastTokenRequestForm(); + form["grant_type"].ShouldBe("refresh_token"); + form["refresh_token"].ShouldBe("old-refresh"); + form["client_id"].ShouldBe("dodossh-desktop"); + } + + [Fact] + public async Task Refresh_CarriesForwardATokenTheProviderDidNotRotate() + { + // Many providers omit refresh_token when they do not rotate. Returning null there would make + // the caller discard a token that is still valid and sign the user out on the next launch. + provider.StubTokenResponse(refreshToken: null); + + var tokens = await CreateClient(new FakeBrowser()) + .RefreshAsync("still-good", TestContext.Current.CancellationToken); + + tokens.RefreshToken.ShouldBe("still-good"); + } + + [Fact] + public async Task AnAbsentExpiresIn_YieldsAShortLifetimeRatherThanNone() + { + // Treating "unspecified" as "never expires" hands the caller a token it never refreshes. + provider.StubTokenResponse(expiresInSeconds: null); + + var before = TimeProvider.System.GetUtcNow(); + + var tokens = await CreateClient(new FakeBrowser()) + .RefreshAsync("refresh", TestContext.Current.CancellationToken); + + tokens.ExpiresAtUtc.ShouldBeGreaterThan(before); + tokens.ExpiresAtUtc.ShouldBeLessThan(before.AddMinutes(10)); + } + + // ---- The key binding ---- + + [Fact] + public async Task AuthorizeKeyBinding_SendsTheNonceAndForcesAFreshLogin() + { + // The primary public-key trust anchor. prompt=login means the assertion attests to a user + // present now, not to a session opened at some unknown earlier time. + var nonce = NonceFor("alice"); + provider.StubTokenResponse(includeIdToken: true, idTokenNonce: nonce); + + var browser = new FakeBrowser(); + + var idToken = await CreateClient(browser) + .AuthorizeKeyBindingAsync(nonce, TestContext.Current.CancellationToken); + + idToken.ShouldNotBeNullOrWhiteSpace(); + + var parameters = browser.AuthorizeParameters; + parameters["nonce"].ShouldBe(nonce); + parameters["prompt"].ShouldBe("login"); + + await browser.CallbackDelivery!; + } + + [Fact] + public async Task AuthorizeKeyBinding_RequestsOnlyOpenid() + { + // A second refresh token would be one more long-lived credential to store for no benefit. + var nonce = NonceFor("alice"); + provider.StubTokenResponse(includeIdToken: true, idTokenNonce: nonce); + + var browser = new FakeBrowser(); + + await CreateClient(browser).AuthorizeKeyBindingAsync(nonce, TestContext.Current.CancellationToken); + + browser.AuthorizeParameters["scope"].ShouldBe("openid"); + + await browser.CallbackDelivery!; + } + + [Fact] + public async Task AuthorizeKeyBinding_RejectsATokenBindingADifferentStatement() + { + // Accepting it would store evidence that verifies against keys we are not publishing, which + // is precisely the fabrication the binding exists to prevent. + var requested = NonceFor("alice"); + var somethingElse = NonceFor("bob"); + + provider.StubTokenResponse(includeIdToken: true, idTokenNonce: somethingElse); + + var exception = await Should.ThrowAsync(async () => + await CreateClient(new FakeBrowser()) + .AuthorizeKeyBindingAsync(requested, TestContext.Current.CancellationToken)); + + exception.Message.ShouldContain("nonce"); + } + + [Fact] + public async Task AuthorizeKeyBinding_RejectsATokenWithNoNonceAtAll() + { + var nonce = NonceFor("alice"); + provider.StubTokenResponse(includeIdToken: true, idTokenNonce: null); + + await Should.ThrowAsync(async () => + await CreateClient(new FakeBrowser()) + .AuthorizeKeyBindingAsync(nonce, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task AuthorizeKeyBinding_RejectsAResponseWithNoIdToken() + { + var nonce = NonceFor("alice"); + provider.StubTokenResponse(includeIdToken: false); + + var exception = await Should.ThrowAsync(async () => + await CreateClient(new FakeBrowser()) + .AuthorizeKeyBindingAsync(nonce, TestContext.Current.CancellationToken)); + + exception.Message.ShouldContain("ID token"); + } + + // ---- Helpers ---- + + private OidcClient CreateClient(IBrowserLauncher browser) => + new(http, browser, TimeProvider.System, OptionsFor(provider.Authority)); + + private static OidcClientOptions OptionsFor(Uri authority) => + new() + { + Authority = authority, + ClientId = "dodossh-desktop", + + // The stub serves plaintext on a loopback port. + RequireHttpsMetadata = false, + }; + + /// A real binding nonce, from a real key statement. + private static string NonceFor(string subject) => + KeyStatementCodec.ComputeNonce(new KeyStatementFields( + Version: 1, + Issuer: "https://idp.example", + Subject: subject, + Email: null, + EncryptionPublicKey: [.. Enumerable.Range(0, 32).Select(i => (byte)(0x40 + i))], + SigningPublicKey: [.. Enumerable.Range(0, 32).Select(i => (byte)(0x60 + i))], + KeyGeneration: 1, + CreatedAt: DateTimeOffset.FromUnixTimeMilliseconds(1_750_000_000_123), + DeviceName: "laptop")); + + /// Recomputes an S256 challenge from a verifier, independently of PkcePair. + private static string Recompute(string? verifier) => + System.Buffers.Text.Base64Url.EncodeToString( + System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.ASCII.GetBytes(verifier ?? string.Empty))); +} diff --git a/tests/DodoSSH.Client.Auth.Tests/StubProvider.cs b/tests/DodoSSH.Client.Auth.Tests/StubProvider.cs new file mode 100644 index 0000000..b235770 --- /dev/null +++ b/tests/DodoSSH.Client.Auth.Tests/StubProvider.cs @@ -0,0 +1,175 @@ +using System.Buffers.Text; +using System.Text; +using System.Text.Json.Nodes; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; + +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) + { + server = WireMockServer.Start(); + 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())); + } +} diff --git a/tests/DodoSSH.Client.Auth.Tests/packages.lock.json b/tests/DodoSSH.Client.Auth.Tests/packages.lock.json new file mode 100644 index 0000000..739c6a5 --- /dev/null +++ b/tests/DodoSSH.Client.Auth.Tests/packages.lock.json @@ -0,0 +1,1398 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.134, )", + "resolved": "3.0.134", + "contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ==" + }, + "Microsoft.CodeAnalysis.BannedApiAnalyzers": { + "type": "Direct", + "requested": "[5.6.0, )", + "resolved": "5.6.0", + "contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw==" + }, + "NSubstitute": { + "type": "Direct", + "requested": "[6.0.0, )", + "resolved": "6.0.0", + "contentHash": "0gvKMbiJ+/WrfbcfBfqRZZrvfLJcd3rqkqVMjjlY5dtmLRVzMY+o/K/rJUStofQ2haSr9Vd04YDfvZtVVGS3/A==", + "dependencies": { + "Castle.Core": "5.1.1" + } + }, + "Shouldly": { + "type": "Direct", + "requested": "[4.3.0, )", + "resolved": "4.3.0", + "contentHash": "sDetrWXrl6YXZ4HeLsdBoNk3uIa7K+V4uvIJ+cqdRa5DrFxeTED7VkjoxCuU1kJWpUuBDZz2QXFzSxBtVXLwRQ==", + "dependencies": { + "DiffEngine": "11.3.0", + "EmptyFiles": "4.4.0" + } + }, + "WireMock.Net": { + "type": "Direct", + "requested": "[2.13.0, )", + "resolved": "2.13.0", + "contentHash": "msedNpcc2vBHSpmdpRmKSxJUsMIIA/MgeJw1OfiOnHEbpxiXDKWNe/LSOGfyMtzzoeWmbQ4XDu1FpwyBG+8JMQ==", + "dependencies": { + "WireMock.Net.GraphQL": "2.13.0", + "WireMock.Net.Matchers.SystemTextJsonPath": "2.13.0", + "WireMock.Net.MimePart": "2.13.0", + "WireMock.Net.Minimal": "2.13.0", + "WireMock.Net.OpenTelemetry": "2.13.0", + "WireMock.Net.ProtoBuf": "2.13.0" + } + }, + "xunit.v3": { + "type": "Direct", + "requested": "[3.2.2, )", + "resolved": "3.2.2", + "contentHash": "L+4/4y0Uqcg8/d6hfnxhnwh4j9FaeULvefTwrk30rr1o4n/vdPfyUQ8k0yzH8VJx7bmFEkDdcRfbtbjEHlaYcA==", + "dependencies": { + "xunit.v3.mtp-v1": "[3.2.2]" + } + }, + "AnyOf": { + "type": "Transitive", + "resolved": "0.5.0.1", + "contentHash": "WDQw5Qos3mhCumSCgKD70TM1dmqBAuJFGv1cFtNTwTaDLZR7kGy33M5C+L0vZV/bNRNwyi5ABvRGPWHL17rkNw==" + }, + "Castle.Core": { + "type": "Transitive", + "resolved": "5.1.1", + "contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==", + "dependencies": { + "System.Diagnostics.EventLog": "6.0.0" + } + }, + "DiffEngine": { + "type": "Transitive", + "resolved": "11.3.0", + "contentHash": "k0ZgZqd09jLZQjR8FyQbSQE86Q7QZnjEzq1LPHtj1R2AoWO8sjV5x+jlSisL7NZAbUOI4y+7Bog8gkr9WIRBGw==", + "dependencies": { + "EmptyFiles": "4.4.0", + "System.Management": "6.0.1" + } + }, + "EmptyFiles": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "gwJEfIGS7FhykvtZoscwXj/XwW+mJY6UbAZk+qtLKFUGWC95kfKXnj8VkxsZQnWBxJemM/q664rGLN5nf+OHZw==" + }, + "Fare": { + "type": "Transitive", + "resolved": "2.2.1", + "contentHash": "21XZo/yuXK1k0EUhdLnjgRD4n0HQYmPFchV6uaORcRc65rasZ1vdm2dmJXPBKZiIBztRRYRmmg/B76W721VWkA==" + }, + "GraphQL": { + "type": "Transitive", + "resolved": "8.5.0", + "contentHash": "BZkfH7GVacTZEkyqa4XN9mW12UA/0XYrpEkkrJnNBf0Pqw8CZWQfItLaWgG2C1Ju9YHH1UUG+LmIpo8iMP6pKA==", + "dependencies": { + "GraphQL-Parser": "9.5.0", + "GraphQL.Analyzers": "8.5.0" + } + }, + "GraphQL-Parser": { + "type": "Transitive", + "resolved": "9.5.0", + "contentHash": "5XWJGKHdVi8pyD4P0EglmJmlXEGs0HzvGlEBf3+/Ve1jLYBBKIOkKvY0Ej17b9Kn1bbBxkrmghqbmsMbkLL1nQ==" + }, + "GraphQL.Analyzers": { + "type": "Transitive", + "resolved": "8.5.0", + "contentHash": "jwfvZD5agmw9J8iZEe6BUfKAY+/lC7EqDQg+6JRwXaQ6G/MCLy8jyBc2SHF/2JdAtrkygc1bVyCUP5mR2PHzVA==" + }, + "GraphQL.NewtonsoftJson": { + "type": "Transitive", + "resolved": "8.5.0", + "contentHash": "tAeUoUhJih5fdZRCV0ue3G/gsu8YBiyNZkgLVFyk0wTk8vJGLTBDJaP5o5LVo1edVnk0bR+0/PaNXAEJxkVrTw==", + "dependencies": { + "GraphQL": "[8.5.0, 9.0.0)", + "Newtonsoft.Json": "13.0.3" + } + }, + "Handlebars.Net": { + "type": "Transitive", + "resolved": "2.1.6", + "contentHash": "WsYWCEXsIM6hEOSOSRHtIYLjC8BnbT5MVmqhNKRqUI7qiv0t8x3nJiBTEv0ZZfvUAMAFnadGIzSsS/U2anVG1Q==" + }, + "Handlebars.Net.Helpers": { + "type": "Transitive", + "resolved": "2.5.5", + "contentHash": "MZ0/Nvy3XdEy/igZD4fJy5HiUKcKUA170Sq+2RmJFsGiWSqlroXzp8TQqJTDCi1aBtETfQOVdBG0YNvuDs9+uQ==", + "dependencies": { + "Handlebars.Net.Helpers.Core": "2.5.5" + } + }, + "Handlebars.Net.Helpers.Core": { + "type": "Transitive", + "resolved": "2.5.5", + "contentHash": "vLTL6UrLUPPiWDCKig8FLhSU+i9J4n/8RfrhadvnvxqziyK0ArxKMT2gLqQ+X/8vJaRcI9zvD5HxA8KjWbq3Dw==", + "dependencies": { + "Handlebars.Net": "2.1.6", + "Stef.Validation": "0.1.1" + } + }, + "Handlebars.Net.Helpers.Humanizer": { + "type": "Transitive", + "resolved": "2.5.5", + "contentHash": "A7TmfLtv7x8HiVckXBmKmOAsO5GKxjSOjxymXS70upqzLLH8BjrhFl+QIGFCdVIWQRx3+yNjGcsz/JXNwt9YZg==", + "dependencies": { + "Handlebars.Net.Helpers": "2.5.5", + "Handlebars.Net.Helpers.Core": "2.5.5", + "Humanizer": "[2.14.1, 4.0.0)" + } + }, + "Handlebars.Net.Helpers.Json": { + "type": "Transitive", + "resolved": "2.5.5", + "contentHash": "iRBo/ik0M8M6ezJt4QzZm5KQptEdeh6bVtnDbieuxh5YPTUsPMFvtoq0gg426PwrahE+5rXoFZmIM11Oy5GwTg==", + "dependencies": { + "Handlebars.Net.Helpers": "2.5.5", + "Handlebars.Net.Helpers.Core": "2.5.5", + "Newtonsoft.Json": "13.0.3" + } + }, + "Handlebars.Net.Helpers.Random": { + "type": "Transitive", + "resolved": "2.5.5", + "contentHash": "zKcfFDN4QxgEjk4Em9yz/PQu0mBpIgEaqjhacg2Fl6M0oSsF7VBVflae2WRM9MtiVeRTwLkVwcy7TvJ6iqFuVQ==", + "dependencies": { + "Handlebars.Net.Helpers": "2.5.5", + "Handlebars.Net.Helpers.Core": "2.5.5", + "RandomDataGenerator.Net": "1.0.19" + } + }, + "Handlebars.Net.Helpers.Xeger": { + "type": "Transitive", + "resolved": "2.5.5", + "contentHash": "J+w9KalIuYlTKMeIv8eoisdoMEz44elri0UOLtfTAuDbADwnBBsJGp4kAQI107+hBcqery9OCRCXm8fvH4eCxQ==", + "dependencies": { + "Fare": "2.2.1", + "Handlebars.Net.Helpers": "2.5.5", + "Handlebars.Net.Helpers.Core": "2.5.5" + } + }, + "Handlebars.Net.Helpers.XPath": { + "type": "Transitive", + "resolved": "2.5.5", + "contentHash": "uUGzjR5w5YCv+BdWQ4RpWAho0tUG0zfAKG5v+abXS6+E+fjbfSshOg7LyoWTVcGTWO0PouukhSMUFaumB2K4tg==", + "dependencies": { + "Handlebars.Net.Helpers": "2.5.5", + "Handlebars.Net.Helpers.Core": "2.5.5", + "XPath2.Extensions": "1.1.5" + } + }, + "Handlebars.Net.Helpers.Xslt": { + "type": "Transitive", + "resolved": "2.5.5", + "contentHash": "bOaX47avO4Uja6jTZcBAgS5KjL/2ZaewCpB0Oy7cVegctPyxiiRx/T44XGSt0133hHry9f5nJVsjFKNLrYq0Pg==", + "dependencies": { + "Handlebars.Net.Helpers": "2.5.5", + "Handlebars.Net.Helpers.Core": "2.5.5" + } + }, + "Humanizer": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "/FUTD3cEceAAmJSCPN9+J+VhGwmL/C12jvwlyM1DFXShEMsBzvLzLqSrJ2rb+k/W2znKw7JyflZgZpyE+tI7lA==", + "dependencies": { + "Humanizer.Core.af": "2.14.1", + "Humanizer.Core.ar": "2.14.1", + "Humanizer.Core.az": "2.14.1", + "Humanizer.Core.bg": "2.14.1", + "Humanizer.Core.bn-BD": "2.14.1", + "Humanizer.Core.cs": "2.14.1", + "Humanizer.Core.da": "2.14.1", + "Humanizer.Core.de": "2.14.1", + "Humanizer.Core.el": "2.14.1", + "Humanizer.Core.es": "2.14.1", + "Humanizer.Core.fa": "2.14.1", + "Humanizer.Core.fi-FI": "2.14.1", + "Humanizer.Core.fr": "2.14.1", + "Humanizer.Core.fr-BE": "2.14.1", + "Humanizer.Core.he": "2.14.1", + "Humanizer.Core.hr": "2.14.1", + "Humanizer.Core.hu": "2.14.1", + "Humanizer.Core.hy": "2.14.1", + "Humanizer.Core.id": "2.14.1", + "Humanizer.Core.is": "2.14.1", + "Humanizer.Core.it": "2.14.1", + "Humanizer.Core.ja": "2.14.1", + "Humanizer.Core.ko-KR": "2.14.1", + "Humanizer.Core.ku": "2.14.1", + "Humanizer.Core.lv": "2.14.1", + "Humanizer.Core.ms-MY": "2.14.1", + "Humanizer.Core.mt": "2.14.1", + "Humanizer.Core.nb": "2.14.1", + "Humanizer.Core.nb-NO": "2.14.1", + "Humanizer.Core.nl": "2.14.1", + "Humanizer.Core.pl": "2.14.1", + "Humanizer.Core.pt": "2.14.1", + "Humanizer.Core.ro": "2.14.1", + "Humanizer.Core.ru": "2.14.1", + "Humanizer.Core.sk": "2.14.1", + "Humanizer.Core.sl": "2.14.1", + "Humanizer.Core.sr": "2.14.1", + "Humanizer.Core.sr-Latn": "2.14.1", + "Humanizer.Core.sv": "2.14.1", + "Humanizer.Core.th-TH": "2.14.1", + "Humanizer.Core.tr": "2.14.1", + "Humanizer.Core.uk": "2.14.1", + "Humanizer.Core.uz-Cyrl-UZ": "2.14.1", + "Humanizer.Core.uz-Latn-UZ": "2.14.1", + "Humanizer.Core.vi": "2.14.1", + "Humanizer.Core.zh-CN": "2.14.1", + "Humanizer.Core.zh-Hans": "2.14.1", + "Humanizer.Core.zh-Hant": "2.14.1" + } + }, + "Humanizer.Core": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" + }, + "Humanizer.Core.af": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "BoQHyu5le+xxKOw+/AUM7CLXneM/Bh3++0qh1u0+D95n6f9eGt9kNc8LcAHLIOwId7Sd5hiAaaav0Nimj3peNw==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.ar": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "3d1V10LDtmqg5bZjWkA/EkmGFeSfNBcyCH+TiHcHP+HGQQmRq3eBaLcLnOJbVQVn3Z6Ak8GOte4RX4kVCxQlFA==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.az": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "8Z/tp9PdHr/K2Stve2Qs/7uqWPWLUK9D8sOZDNzyv42e20bSoJkHFn7SFoxhmaoVLJwku2jp6P7HuwrfkrP18Q==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.bg": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "S+hIEHicrOcbV2TBtyoPp1AVIGsBzlarOGThhQYCnP6QzEYo/5imtok6LMmhZeTnBFoKhM8yJqRfvJ5yqVQKSQ==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.bn-BD": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "U3bfj90tnUDRKlL1ZFlzhCHoVgpTcqUlTQxjvGCaFKb+734TTu3nkHUWVZltA1E/swTvimo/aXLtkxnLFrc0EQ==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.cs": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "jWrQkiCTy3L2u1T86cFkgijX6k7hoB0pdcFMWYaSZnm6rvG/XJE40tfhYyKhYYgIc1x9P2GO5AC7xXvFnFdqMQ==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.da": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "5o0rJyE/2wWUUphC79rgYDnif/21MKTTx9LIzRVz9cjCIVFrJ2bDyR2gapvI9D6fjoyvD1NAfkN18SHBsO8S9g==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.de": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "9JD/p+rqjb8f5RdZ3aEJqbjMYkbk4VFii2QDnnOdNo6ywEfg/A5YeOQ55CaBJmy7KvV4tOK4+qHJnX/tg3Z54A==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.el": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "Xmv6sTL5mqjOWGGpqY7bvbfK5RngaUHSa8fYDGSLyxY9mGdNbDcasnRnMOvi0SxJS9gAqBCn21Xi90n2SHZbFA==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.es": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "e//OIAeMB7pjBV1HqqI4pM2Bcw3Jwgpyz9G5Fi4c+RJvhqFwztoWxW57PzTnNJE2lbhGGLQZihFZjsbTUsbczA==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.fa": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "nzDOj1x0NgjXMjsQxrET21t1FbdoRYujzbmZoR8u8ou5CBWY1UNca0j6n/PEJR/iUbt4IxstpszRy41wL/BrpA==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.fi-FI": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "Vnxxx4LUhp3AzowYi6lZLAA9Lh8UqkdwRh4IE2qDXiVpbo08rSbokATaEzFS+o+/jCNZBmoyyyph3vgmcSzhhQ==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.fr": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "2p4g0BYNzFS3u9SOIDByp2VClYKO0K1ecDV4BkB9EYdEPWfFODYnF+8CH8LpUrpxL2TuWo2fiFx/4Jcmrnkbpg==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.fr-BE": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "o6R3SerxCRn5Ij8nCihDNMGXlaJ/1AqefteAssgmU2qXYlSAGdhxmnrQAXZUDlE4YWt/XQ6VkNLtH7oMqsSPFQ==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.he": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "FPsAhy7Iw6hb+ZitLgYC26xNcgGAHXb0V823yFAzcyoL5ozM+DCJtYfDPYiOpsJhEZmKFTM9No0jUn1M89WGvg==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.hr": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "chnaD89yOlST142AMkAKLuzRcV5df3yyhDyRU5rypDiqrq2HN8y1UR3h1IicEAEtXLoOEQyjSAkAQ6QuXkn7aw==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.hu": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "hAfnaoF9LTGU/CmFdbnvugN4tIs8ppevVMe3e5bD24+tuKsggMc5hYta9aiydI8JH9JnuVmxvNI4DJee1tK05A==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.hy": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "sVIKxOiSBUb4gStRHo9XwwAg9w7TNvAXbjy176gyTtaTiZkcjr9aCPziUlYAF07oNz6SdwdC2mwJBGgvZ0Sl2g==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.id": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "4Zl3GTvk3a49Ia/WDNQ97eCupjjQRs2iCIZEQdmkiqyaLWttfb+cYXDMGthP42nufUL0SRsvBctN67oSpnXtsg==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.is": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "R67A9j/nNgcWzU7gZy1AJ07ABSLvogRbqOWvfRDn4q6hNdbg/mjGjZBp4qCTPnB2mHQQTCKo3oeCUayBCNIBCw==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.it": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "jYxGeN4XIKHVND02FZ+Woir3CUTyBhLsqxu9iqR/9BISArkMf1Px6i5pRZnvq4fc5Zn1qw71GKKoCaHDJBsLFw==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.ja": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "TM3ablFNoYx4cYJybmRgpDioHpiKSD7q0QtMrmpsqwtiiEsdW5zz/q4PolwAczFnvrKpN6nBXdjnPPKVet93ng==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.ko-KR": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "CtvwvK941k/U0r8PGdEuBEMdW6jv/rBiA9tUhakC7Zd2rA/HCnDcbr1DiNZ+/tRshnhzxy/qwmpY8h4qcAYCtQ==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.ku": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "vHmzXcVMe+LNrF9txpdHzpG7XJX65SiN9GQd/Zkt6gsGIIEeECHrkwCN5Jnlkddw2M/b0HS4SNxdR1GrSn7uCA==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.lv": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "E1/KUVnYBS1bdOTMNDD7LV/jdoZv/fbWTLPtvwdMtSdqLyRTllv6PGM9xVQoFDYlpvVGtEl/09glCojPHw8ffA==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.ms-MY": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "vX8oq9HnYmAF7bek4aGgGFJficHDRTLgp/EOiPv9mBZq0i4SA96qVMYSjJ2YTaxs7Eljqit7pfpE2nmBhY5Fnw==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.mt": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "pEgTBzUI9hzemF7xrIZigl44LidTUhNu4x/P6M9sAwZjkUF0mMkbpxKkaasOql7lLafKrnszs0xFfaxQyzeuZQ==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.nb": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "mbs3m6JJq53ssLqVPxNfqSdTxAcZN3njlG8yhJVx83XVedpTe1ECK9aCa8FKVOXv93Gl+yRHF82Hw9T9LWv2hw==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.nb-NO": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "AsJxrrVYmIMbKDGe8W6Z6//wKv9dhWH7RsTcEHSr4tQt/80pcNvLi0hgD3fqfTtg0tWKtgch2cLf4prorEV+5A==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.nl": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "24b0OUdzJxfoqiHPCtYnR5Y4l/s4Oh7KW7uDp+qX25NMAHLCGog2eRfA7p2kRJp8LvnynwwQxm2p534V9m55wQ==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.pl": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "17mJNYaBssENVZyQHduiq+bvdXS0nhZJGEXtPKoMhKv3GD//WO0mEfd9wjEBsWCSmWI7bjRqhCidxzN+YtJmsg==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.pt": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "8HB8qavcVp2la1GJX6t+G9nDYtylPKzyhxr9LAooIei9MnQvNsjEiIE4QvHoeDZ4weuQ9CsPg1c211XUMVEZ4A==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.ro": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "psXNOcA6R8fSHoQYhpBTtTTYiOk8OBoN3PKCEDgsJKIyeY5xuK81IBdGi77qGZMu/OwBRQjQCBMtPJb0f4O1+A==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.ru": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "zm245xUWrajSN2t9H7BTf84/2APbUkKlUJpcdgsvTdAysr1ag9fi1APu6JEok39RRBXDfNRVZHawQ/U8X0pSvQ==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.sk": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "Ncw24Vf3ioRnbU4MsMFHafkyYi8JOnTqvK741GftlQvAbULBoTz2+e7JByOaasqeSi0KfTXeegJO+5Wk1c0Mbw==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.sl": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "l8sUy4ciAIbVThWNL0atzTS2HWtv8qJrsGWNlqrEKmPwA4SdKolSqnTes9V89fyZTc2Q43jK8fgzVE2C7t009A==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.sr": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "rnNvhpkOrWEymy7R/MiFv7uef8YO5HuXDyvojZ7JpijHWA5dXuVXooCOiA/3E93fYa3pxDuG2OQe4M/olXbQ7w==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.sr-Latn": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "nuy/ykpk974F8ItoQMS00kJPr2dFNjOSjgzCwfysbu7+gjqHmbLcYs7G4kshLwdA4AsVncxp99LYeJgoh1JF5g==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.sv": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "E53+tpAG0RCp+cSSI7TfBPC+NnsEqUuoSV0sU+rWRXWr9MbRWx1+Zj02XMojqjGzHjjOrBFBBio6m74seFl0AA==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.th-TH": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "eSevlJtvs1r4vQarNPfZ2kKDp/xMhuD00tVVzRXkSh1IAZbBJI/x2ydxUOwfK9bEwEp+YjvL1Djx2+kw7ziu7g==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.tr": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "rQ8N+o7yFcFqdbtu1mmbrXFi8TQ+uy+fVH9OPI0CI3Cu1om5hUU/GOMC3hXsTCI6d79y4XX+0HbnD7FT5khegA==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.uk": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "2uEfujwXKNm6bdpukaLtEJD+04uUtQD65nSGCetA1fYNizItEaIBUboNfr3GzJxSMQotNwGVM3+nSn8jTd0VSg==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.uz-Cyrl-UZ": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "TD3ME2sprAvFqk9tkWrvSKx5XxEMlAn1sjk+cYClSWZlIMhQQ2Bp/w0VjX1Kc5oeKjxRAnR7vFcLUFLiZIDk9Q==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.uz-Latn-UZ": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "/kHAoF4g0GahnugZiEMpaHlxb+W6jCEbWIdsq9/I1k48ULOsl/J0pxZj93lXC3omGzVF1BTVIeAtv5fW06Phsg==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.vi": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "rsQNh9rmHMBtnsUUlJbShMsIMGflZtPmrMM6JNDw20nhsvqfrdcoDD8cMnLAbuSovtc3dP+swRmLQzKmXDTVPA==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.zh-CN": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "uH2dWhrgugkCjDmduLdAFO9w1Mo0q07EuvM0QiIZCVm6FMCu/lGv2fpMu4GX+4HLZ6h5T2Pg9FIdDLCPN2a67w==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.zh-Hans": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "WH6IhJ8V1UBG7rZXQk3dZUoP2gsi8a0WkL8xL0sN6WGiv695s8nVcmab9tWz20ySQbuzp0UkSxUQFi5jJHIpOQ==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.zh-Hant": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "VIXB7HCUC34OoaGnO3HJVtSv2/wljPhjV7eKH4+TFPgQdJj2lvHNKY41Dtg0Bphu7X5UaXFR4zrYYyo+GNOjbA==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "JmesPath.Net": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "sL1LeqBm+BWSKvgZN/T470IqkXcKQXmOYsRUZU18jDZeiIBmvUfIe9m3VhiII/jOK/6WmrQ+W8Pqwz3k28WX9g==", + "dependencies": { + "JmesPath.Net.Parser": "1.1.0", + "Newtonsoft.Json": "13.0.4" + } + }, + "JmesPath.Net.Parser": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "NLTE/dPy8lMcZO6E7SL5Jw3fay8Vesll7+hkeRVSRaVNg1RRyPBV3/u6CM7QNgtnzhvyFPDyxUHUuRdh0vzCSg==" + }, + "Json.More.Net": { + "type": "Transitive", + "resolved": "3.0.1", + "contentHash": "fRctF2J2SILYG6wqP21drmeEODmCVkVQ/b3MndDu2fT1swfySyUgq7ePCk+aENGlDcIm05fyfjh9vcuqDEfv3w==" + }, + "JsonConverter.Abstractions": { + "type": "Transitive", + "resolved": "0.13.0", + "contentHash": "Ci3nuKx3GgMDfW9JA4dJpU+hJV5G1ve72mploQP8ivSDpOmo2QbfVAQkxsVzb3UQJCgwxxM6rdE/fPXwM0yj0g==" + }, + "JsonConverter.Newtonsoft.Json": { + "type": "Transitive", + "resolved": "0.13.0", + "contentHash": "K6doeW12emLiJV4laUf58y3kkjng6/IARRtC7+20qIOBrP1pBxGRsjz2IfxCgSBkTML3q6FWe7O+/UE374lsaA==", + "dependencies": { + "JsonConverter.Abstractions": "0.13.0", + "Newtonsoft.Json": "13.0.4", + "Stef.Validation": "0.1.1" + } + }, + "JsonConverter.System.Text.Json": { + "type": "Transitive", + "resolved": "0.13.0", + "contentHash": "UtRbkZT16Z0OVZ9n/h60E0GlUDTul/DjpuuajdsCNvefsmTxf6WNB8Cq9Hjwu9pGjfqOaFOmaz8k54DaHBmc0g==", + "dependencies": { + "JsonConverter.Abstractions": "0.13.0", + "Stef.Validation": "0.1.1" + } + }, + "JsonPath.Net": { + "type": "Transitive", + "resolved": "3.0.2", + "contentHash": "Cmt2mvPYOLljjqSfM1xUYZYTPf8MPbwv2XpCpPxxq9u23/CGrz/fljgd1fJUNujd3+E1adNOyF1TLwppbyQwxg==", + "dependencies": { + "Json.More.Net": "3.0.1" + } + }, + "MetadataReferenceService.Abstractions": { + "type": "Transitive", + "resolved": "0.0.1", + "contentHash": "Sf5ip58vlqWkQIAULIOKFIIFuhtRd8lChsJRZdFo746NVApEp/qgxNf/zCLjbB/RA/8TQGXWrFPKpqjyeh3EMg==", + "dependencies": { + "Microsoft.CodeAnalysis.CSharp": "4.8.0", + "Stef.Validation": "0.1.1" + } + }, + "MetadataReferenceService.Default": { + "type": "Transitive", + "resolved": "0.0.1", + "contentHash": "ihrchqYobpQMA9tn0W+MGD3oe5onqCttbR3lQfEiVzwF0V9/DS+K4YtvsUPGDC9XIie2Xw3lugSSk97k+OUwnQ==", + "dependencies": { + "MetadataReferenceService.Abstractions": "0.0.1" + } + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.AspNetCore.Http": { + "type": "Transitive", + "resolved": "2.3.9", + "contentHash": "+CcfWi1LoKYbcxt+3toO4xbBG+qSSMbPuuow+cbZKIrITXuu1geN1traamL4jG8QaHdHGm3M0eCh+EOgdMgNPA==", + "dependencies": { + "Microsoft.AspNetCore.Http.Abstractions": "2.3.0", + "Microsoft.AspNetCore.WebUtilities": "2.3.0", + "Microsoft.Extensions.ObjectPool": "8.0.11", + "Microsoft.Extensions.Options": "8.0.2", + "Microsoft.Net.Http.Headers": "2.3.8" + } + }, + "Microsoft.AspNetCore.Http.Abstractions": { + "type": "Transitive", + "resolved": "2.3.0", + "contentHash": "39r9PPrjA6s0blyFv5qarckjNkaHRA5B+3b53ybuGGNTXEj1/DStQJ4NWjFL6QTRQpL9zt7nDyKxZdJOlcnq+Q==", + "dependencies": { + "Microsoft.AspNetCore.Http.Features": "2.3.0" + } + }, + "Microsoft.AspNetCore.Http.Features": { + "type": "Transitive", + "resolved": "2.3.0", + "contentHash": "f10WUgcsKqrkmnz6gt8HeZ7kyKjYN30PO7cSic1lPtH7paPtnQqXPOveul/SIPI43PhRD4trttg4ywnrEmmJpA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.AspNetCore.WebUtilities": { + "type": "Transitive", + "resolved": "2.3.0", + "contentHash": "trbXdWzoAEUVd0PE2yTopkz4kjZaAIA7xUWekd5uBw+7xE8Do/YOVTeb9d9koPTlbtZT539aESJjSLSqD8eYrQ==", + "dependencies": { + "Microsoft.Net.Http.Headers": "2.3.0" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Transitive", + "resolved": "3.3.4", + "contentHash": "AxkxcPR+rheX0SmvpLVIGLhOUXAKG56a64kV9VQZ4y9gR9ZmPXnqZvHJnmwLSwzrEP6junUF11vuc+aqo5r68g==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "4.8.0", + "contentHash": "/jR+e/9aT+BApoQJABlVCKnnggGQbvGh7BKq2/wI1LamxC+LbzhcLj4Vj7gXCofl1n4E521YfF9w0WcASGg/KA==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.3.4" + } + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Transitive", + "resolved": "4.8.0", + "contentHash": "+3+qfdb/aaGD8PZRCrsdobbzGs1m9u119SkkJt8e/mk3xLJz/udLtS2T6nY27OTXxBBw10HzAbC8Z9w08VyP/g==", + "dependencies": { + "Microsoft.CodeAnalysis.Common": "[4.8.0]" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "H4SWETCh/cC5L1WtWchHR6LntGk3rDTTznZMssr4cL8IbDmMWBxY+MOGDc/ASnqNolLKPIWHWeuC1ddiL/iNPw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "d2kDKnCsJvY7mBVhcjPSp9BkJk48DsaHPg5u+Oy4f8XaOqnEedRy/USyvnpHL92wpJ6DrTPy7htppUUzskbCXQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "tMF9wNh+hlyYDWB8mrFCQHQmWHlRosol1b/N2Jrefy1bFLnuTlgSYmPyHNmz8xVQgs7DpXytBRWxGhG+mSTp0g==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "f0RBabswJq+gRu5a+hWIobrLWiUYPKMhCD9WO3sYBAdSy3FFH14LMvLVFZc2kPSCimBLxSuitUhsd6tb0TAY6A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "L3AdmZ1WOK4XXT5YFPEwyt0ep6l8lGIPs7F5OOBZc77Zqeo01Of7XXICy47628sdVl0v/owxYJTe86DTgFwKCA==" + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "SfK89ytD61S7DgzorFljSkUeluC1ncn6dtZgwc0ot39f/BEYWBl5jpgvodxduoYAs1d9HG8faCDRZxE95UMo2A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0" + } + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "/ppSdehKk3fuXjlqCDgSOtjRK/pSHU8eWgzSHfHdwVm5BP4Dgejehkw+PtxKG2j98qTDEHDst2Y99aNsmJldmw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "KrN6TGFwCwqOkLLk/idW/XtDQh+8In+CL9T4M1Dx+5ScsjTq4TlVbal8q532m82UYrMr6RiQJF2HvYCN0QwVsA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "BStFkd5CcnEtarlcgYDBcFzGYCuuNMzPs02wN3WBsOFoYIEmYoUdAiU+au6opzoqfTYJsMTW00AeqDdnXH2CvA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "FU/IfjDfwaMuKr414SSQNTIti/69bHEMb+QKrskRb26oVqpx3lNFXMjs/RC9ZUuhBhcwDM2BwOgoMw+PZ+beqQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.Logging.Configuration": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "j8zcwhS6bYB6FEfaY3nYSgHdpiL2T+/V3xjpHtslVAegyI1JUbB9yAt/BFdvZdsNbY0Udm4xFtvfT/hUwcOOOg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.Configuration.Binder": "10.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.0" + } + }, + "Microsoft.Extensions.ObjectPool": { + "type": "Transitive", + "resolved": "8.0.11", + "contentHash": "6ApKcHNJigXBfZa6XlDQ8feJpq7SG1ogZXg6M4FiNzgd6irs3LUAzo0Pfn4F2ZI9liGnH1XIBR/OtSbZmJAV5w==" + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "8oCAgXOow5XDrY9HaXX1QmH3ORsyZO/ANVHBlhLyCeWTH5Sg4UuqZeOTWJi6484M+LqSx0RqQXDJtdYy2BNiLQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "tL9cSl3maS5FPzp/3MtlZI21ExWhni0nnUCF8HY4npTsINw45n9SNDbkKXBMtFyUFGSsQep25fHIDN4f/Vp3AQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.Configuration.Binder": "10.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "inRnbpCS0nwO/RuoZIAqxQUuyjaknOOnCEZB55KSMMjRhl0RQDttSmLSGsUJN3RQ3ocf5NDLFd2mOQViHqMK5w==" + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "6.34.0", + "contentHash": "5nInt1KKSpKQBlhe6gXz4yKxRzRUQa21vCvSIIKKzAI2e1r9PHQOZc7aRzBA8L/JCvBxLbCxelvUqun6qwWPJg==" + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Transitive", + "resolved": "6.34.0", + "contentHash": "CZMom/ZoWcgjxLMxmCmcEkuoA0OA4swN1CGeMBQyxF/hEZgRbWK9EnWVJ9/oMUq3D1+OGJjnbN+W6gFq9kZcEg==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "6.34.0" + } + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "6.34.0", + "contentHash": "E0AbluNkI30/VKa96PxJhhFZDx/NGYIXFrRIRq1N5/V0TToaiuc3hM90QLFszT2BBQefnp/wjm12ilSudmt9bg==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "6.34.0" + } + }, + "Microsoft.IdentityModel.Protocols": { + "type": "Transitive", + "resolved": "6.34.0", + "contentHash": "xrqYK+V3FW+fMQ5oI7cwku2wj1RHz8qym3kh+rD+BTgCw1RmfFyWrLQ8/rVEqTl2nn4NcC0N+sHk0Q4qQ8dK9A==", + "dependencies": { + "Microsoft.IdentityModel.Logging": "6.34.0", + "Microsoft.IdentityModel.Tokens": "6.34.0" + } + }, + "Microsoft.IdentityModel.Protocols.OpenIdConnect": { + "type": "Transitive", + "resolved": "6.34.0", + "contentHash": "SN3eZtssgpfnTCUlKsTJn9/0UiSc/HsbGLFl5Xp8vXFLXBeweWiDu54jFngSirjtJd6lSw3GgZhK5LZvVXGGLQ==", + "dependencies": { + "Microsoft.IdentityModel.Protocols": "6.34.0", + "System.IdentityModel.Tokens.Jwt": "6.34.0" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "Transitive", + "resolved": "6.34.0", + "contentHash": "PEPcGMqbEwEwbpQ6nTld9Nqq6V5BPZSOfk71qXZ7h7DuGuxa13bWvjImhJba5Ko88YvIuZuOBJWFZmjLfwbNXA==", + "dependencies": { + "Microsoft.IdentityModel.Logging": "6.34.0" + } + }, + "Microsoft.Net.Http.Headers": { + "type": "Transitive", + "resolved": "2.3.8", + "contentHash": "JO60u/VVUdaZfv4XQ//zgcH54y8rnxdpcvXnsDqWLKB4adDKaCiaozixDfQ/6H+PKYfkNV2CL8b8U+F9mciE3Q==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "No5AudZMmSb+uNXjlgL2y3/stHD2IT4uxqc5yHwkE+/nNux9jbKcaJMvcp9SwgP4DVD8L9/P3OUz8mmmcvEIdQ==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "AL46Xe1WBi85Ntd4mNPvat5ZSsZ2uejiVqoKCypr8J3wK0elA5xJ3AN4G/Q4GIwzUFnggZoH/DBjnr9J18IO/g==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "QafNtNSmEI0zazdebnsIkDKmFtTSpmx/5PLOjURWwozcPb3tvRxzosQSL8xwYNM1iPhhKiBksXZyRSE2COisrA==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "oTUtyR4X/s9ytuiNA29FGsNCCH0rNmY5Wdm14NCKLjTM1cT9edVSlA+rGS/mVmusPqcP0l/x9qOnMXg16v87RQ==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" + }, + "Namotion.Reflection": { + "type": "Transitive", + "resolved": "2.1.2", + "contentHash": "7tSHAzX8GWKy0qrW6OgQWD7kAZiqzhq+m1503qczuwuK6ZYhOGCQUxw+F3F4KkRM70aB6RMslsRVSCFeouIehw==" + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.4", + "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A==" + }, + "NJsonSchema": { + "type": "Transitive", + "resolved": "10.9.0", + "contentHash": "IBPo6Srxn2MEcIFM3HdM4QImrJbsIeujENQyzHL2Pv6wLsKSYAyAEilecRqaLOhoy3snEiPLx7hhv7opbhOxKQ==", + "dependencies": { + "Namotion.Reflection": "2.1.2", + "Newtonsoft.Json": "9.0.1" + } + }, + "NJsonSchema.Extensions": { + "type": "Transitive", + "resolved": "0.2.0", + "contentHash": "zLHUfuCmnaaQbKxqvTALrxhXV6Pbdy4G3ZlAI+7oaXdJmSyQPpMHGcxDBmw0+qHziT7jVImxU1BjcidKJHeprg==", + "dependencies": { + "NJsonSchema": "10.9.0" + } + }, + "NSwag.Core": { + "type": "Transitive", + "resolved": "13.16.1", + "contentHash": "xiX+H3Bv6zxrqJExPepO5WQVutkDUMdlUA3NqQ8VguwsYwJlkV05eF8XvmbJn/yGJWUag7vLImuXAoj0/327Bg==", + "dependencies": { + "NJsonSchema": "10.7.2", + "Newtonsoft.Json": "9.0.1" + } + }, + "OpenTelemetry": { + "type": "Transitive", + "resolved": "1.15.3", + "contentHash": "N0i6WjPoHPbZyms1ugbDIFAJFuGlpeExJMU/+XSL0lQRUkg/D0utFkDoLXf8Z1km5B+xVZ2GyMXXiX8qdeNmPg==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Configuration": "10.0.0", + "OpenTelemetry.Api.ProviderBuilderExtensions": "1.15.3" + } + }, + "OpenTelemetry.Api": { + "type": "Transitive", + "resolved": "1.15.3", + "contentHash": "fX+fkCysfPut+qCcT3bKqyX4QN9Saf4CgX8HLOHywEVD+Xr7sULtfuypITpoDysjx8R59dn/3mWhgimMH8cm/g==" + }, + "OpenTelemetry.Api.ProviderBuilderExtensions": { + "type": "Transitive", + "resolved": "1.15.3", + "contentHash": "SYn0lqYDwLMWhv/zlNGsQcl2yX++yTumanX46bmOZE/ZDOd1WjPBO2kZaZgKLEZTZk48pavIFGJ6vOvxXgWVFQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "OpenTelemetry.Api": "1.15.3" + } + }, + "OpenTelemetry.Exporter.OpenTelemetryProtocol": { + "type": "Transitive", + "resolved": "1.15.3", + "contentHash": "FEXJepcseTGbATiCkUfP7ipoFEYYfl/0UmmUwi0KxCPg9PaUA8ab2P1LGopK+/HExasJ1ZutFhZrN6WvUIR23g==", + "dependencies": { + "OpenTelemetry": "1.15.3" + } + }, + "OpenTelemetry.Extensions.Hosting": { + "type": "Transitive", + "resolved": "1.15.3", + "contentHash": "u8n/W8yIlqv0BXZmvId1iVaeWXG42tGKdTkuLYg5g57Y/r9CeUNzqtrSHNdG5IoO8iPX79w3v+WsbAHgUQbfeg==", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", + "OpenTelemetry": "1.15.3" + } + }, + "OpenTelemetry.Instrumentation.AspNetCore": { + "type": "Transitive", + "resolved": "1.15.2", + "contentHash": "2nPd7r0ug/gd6/CNFL6Rlu+RSQ9WYGSGHAYQ1ssbSqyzKJpqTunfx2I/1O0WB5k+L0cyXbG4XVZpoSoUc3M7wg==", + "dependencies": { + "OpenTelemetry.Api.ProviderBuilderExtensions": "[1.15.3, 2.0.0)" + } + }, + "protobuf-net": { + "type": "Transitive", + "resolved": "3.2.52", + "contentHash": "XbZurNU3B/VaL/5OJ0kshO+AWxsZroI1saKuLfZpDwH2ngb2K9bdF1nIW6elFOViZw7TQCmfVZapxrMKCDqecQ==", + "dependencies": { + "protobuf-net.Core": "3.2.52" + } + }, + "protobuf-net.Core": { + "type": "Transitive", + "resolved": "3.2.52", + "contentHash": "zOpGtUo2QTgbsiI0D0yCe8aUTgDPov6kqIu1CDHI6isqhYcAHdirRrdnfsQXmAUfAWx1LwVYGgC6xe6fNS4UAg==" + }, + "ProtoBufJsonConverter": { + "type": "Transitive", + "resolved": "0.11.0", + "contentHash": "lxvcZQlCtgYZpfm9hhAJVZ1jsPkb9g3fyaAOnQLyEu8MiAowEIdED6jzwfjqLqOhY4AahqnbfRvZ904Ud43X7w==", + "dependencies": { + "MetadataReferenceService.Default": "0.0.1", + "Microsoft.CodeAnalysis.CSharp": "4.8.0", + "Newtonsoft.Json": "13.0.3", + "Stef.Validation": "0.1.1", + "protobuf-net": "3.2.52" + } + }, + "RamlToOpenApiConverter.SourceOnly": { + "type": "Transitive", + "resolved": "0.21.0", + "contentHash": "x0g2c4tgPC5i+ZofhlhSeiWAsOjzkatv523tKGglx0mL05JYevQ5sYPP4r0xthpRAv99/6EuBJ6TbbipsHTMzA==" + }, + "RandomDataGenerator.Net": { + "type": "Transitive", + "resolved": "1.0.19.1", + "contentHash": "OkAqBA69VbYYg+biX2DYWcucOI/yEivkdJ/XPqife/mQAC0r/NArcAU/EI3i/1oRFUUCjibV0b7qEjK+TYTqDw==", + "dependencies": { + "Fare": "2.2.1", + "Stef.Validation": "0.1.1" + } + }, + "Scriban.Signed": { + "type": "Transitive", + "resolved": "7.2.5", + "contentHash": "Fu1AjcAyrZIAW9LIhxVgVyo5EMVdwLhXagKKI2A1UZoI0Wvz2CiRT+VXp1tuiMq2JhlkjVyebj/JQcF4koZacg==" + }, + "SharpYaml": { + "type": "Transitive", + "resolved": "2.1.4", + "contentHash": "/iwULhVBpTjD4wPZhLU+eUWBanDvri/2AGx5YbaAj5kp9kXzhqUfJEy56H5Yi+c+OXsdm/oKD1aTKB24BFp8cw==" + }, + "SimMetrics.Net": { + "type": "Transitive", + "resolved": "1.0.5", + "contentHash": "LaSDYOJDh2WncgRboqiWtk/Igqoim/LV7v808qBeWY/f36Ol5oEKguEYpKrWw5ap8KYP0SRXf7/v3zil9koY6Q==" + }, + "Stef.Validation": { + "type": "Transitive", + "resolved": "0.3.0", + "contentHash": "OfzmxQMK4eBzmobph43p1NsLTgVAC3XGTcvQS0odhsdL6uS7UsFzBMK6S9mAfIijZdLW4q98aZ3dtTOeTaQo6Q==" + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA==" + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "lcyUiXTsETK2ALsZrX+nWuHSIQeazhqPphLfaRxzdGaG93+0kELqpgEHtwWOlQe7+jSFnKwaCAgL4kjeZCQJnw==" + }, + "System.IdentityModel.Tokens.Jwt": { + "type": "Transitive", + "resolved": "6.34.0", + "contentHash": "c0misfmFT3QxKY+a16PGlj+DtiUzoPaf26m2avyPZaLRc9vlIdLtmovfRY5MqN+y/SEoBSRXrgVaeZGPgFQQ6w==", + "dependencies": { + "Microsoft.IdentityModel.JsonWebTokens": "6.34.0", + "Microsoft.IdentityModel.Tokens": "6.34.0" + } + }, + "System.Management": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "10J1D0h/lioojphfJ4Fuh5ZUThT/xOVHdV9roGBittKKNP2PMjrvibEdbVTGZcPra1399Ja3tqIJLyQrc5Wmhg==", + "dependencies": { + "System.CodeDom": "6.0.0" + } + }, + "TinyMapper.Signed": { + "type": "Transitive", + "resolved": "4.0.0", + "contentHash": "W5uc9QXp8PUgP3VQ1Qyt3vK8ptyjj38tJ7nEAtRKA6R/4e6+2gsgYrAmRg9fCK1hhe3E0yeAm5acC14qx2CINg==" + }, + "WireMock.Net.Abstractions": { + "type": "Transitive", + "resolved": "2.13.0", + "contentHash": "7ZmPVJxlSBj0E7d47PHLydz15w0TEJ6WH2m7T/hucT3QfxbhN07AV0mwBoyO/T6jv+Af+hgEgmlep3v9TACxPw==" + }, + "WireMock.Net.GraphQL": { + "type": "Transitive", + "resolved": "2.13.0", + "contentHash": "3nMUB7E8ner6fpdOZe91qx/1O3ebEOke1dCeCw+wIvnmtPhlfdOuss2saICqc1nrwLv2bAOVQ29606Ayinf7kw==", + "dependencies": { + "GraphQL.NewtonsoftJson": "8.5.0", + "WireMock.Net.Shared": "2.13.0" + } + }, + "WireMock.Net.Matchers.SystemTextJsonPath": { + "type": "Transitive", + "resolved": "2.13.0", + "contentHash": "uhuQW2mFvi2X3v5Om3nDYYaF/ApkDHsFB0A1pklyLJnBUp1iUp+6Imt3t0j1bpMggHKaXJXEhDCqoDxvGP4FIQ==", + "dependencies": { + "JsonPath.Net": "3.0.2", + "WireMock.Net.Shared": "2.13.0" + } + }, + "WireMock.Net.MimePart": { + "type": "Transitive", + "resolved": "2.13.0", + "contentHash": "bFJsUJ+zKOZC8FMO+8kyRHW51Qt5DkKRKS7//dzuBcuJVY2XdEnOdfaAlMfnN9T73XrHCi8skU+G1xdfWD2x3g==", + "dependencies": { + "Stef.Validation": "0.3.0", + "WireMock.Net.Shared": "2.13.0" + } + }, + "WireMock.Net.Minimal": { + "type": "Transitive", + "resolved": "2.13.0", + "contentHash": "7VIKKuEiAHa19x7rbyf1s90UymTVQVTXz1CZc9mUM7DdW1LdaYyHUqBK4mBycvzsxuam7+jkU7l+aMJGAb0kBA==", + "dependencies": { + "JmesPath.Net": "1.1.0", + "Microsoft.IdentityModel.Protocols.OpenIdConnect": "6.34.0", + "NJsonSchema.Extensions": "0.2.0", + "NSwag.Core": "13.16.1", + "Scriban.Signed": "7.2.5", + "SimMetrics.Net": "1.0.5", + "TinyMapper.Signed": "4.0.0", + "WireMock.Net.OpenApiParser": "2.13.0", + "WireMock.Net.Shared": "2.13.0", + "WireMock.Org.Abstractions": "2.13.0" + } + }, + "WireMock.Net.OpenApiParser": { + "type": "Transitive", + "resolved": "2.13.0", + "contentHash": "XQ2hgycULdhp1F16OqixwDwz2zaPtk3rpdurmL9MLZjr8TUWMCyHfStXeaU/vHu/of/xnYAh+qB0dwtdmGwY0Q==", + "dependencies": { + "Newtonsoft.Json": "13.0.4", + "RamlToOpenApiConverter.SourceOnly": "0.21.0", + "RandomDataGenerator.Net": "1.0.19.1", + "SharpYaml": "2.1.4", + "Stef.Validation": "0.3.0", + "WireMock.Net.Abstractions": "2.13.0", + "YamlDotNet": "18.1.0" + } + }, + "WireMock.Net.OpenTelemetry": { + "type": "Transitive", + "resolved": "2.13.0", + "contentHash": "3SjRQcAd1pPZXB7jtj7vx7cbWdkskQl030W2QJldIOf+P9VtvKPJ5SsNCKI43Eg5O7RyrYuy2AQVdik7lbUi/Q==", + "dependencies": { + "OpenTelemetry.Exporter.OpenTelemetryProtocol": "1.15.3", + "OpenTelemetry.Extensions.Hosting": "1.15.3", + "OpenTelemetry.Instrumentation.AspNetCore": "1.15.2", + "WireMock.Net.Shared": "2.13.0" + } + }, + "WireMock.Net.ProtoBuf": { + "type": "Transitive", + "resolved": "2.13.0", + "contentHash": "WRG6cujOXRe3ZiStkElH82lOlDhS3YiV/cpXQbnCLpTkf+F/RBmrivzqO7ILcTccVKdwiIbT+ANdCR0vVCXZMg==", + "dependencies": { + "ProtoBufJsonConverter": "0.11.0", + "WireMock.Net.Shared": "2.13.0" + } + }, + "WireMock.Net.Shared": { + "type": "Transitive", + "resolved": "2.13.0", + "contentHash": "vvA0ssOFv3IoQI5UL5dr3mtAgF5imyit999sWEJYtXp7lLeA/fGvmbYLEpG5CeQ1RRtEcIxnJkRdtcttYmIC4Q==", + "dependencies": { + "AnyOf": "0.5.0.1", + "Handlebars.Net.Helpers": "2.5.5", + "Handlebars.Net.Helpers.Humanizer": "2.5.5", + "Handlebars.Net.Helpers.Json": "2.5.5", + "Handlebars.Net.Helpers.Random": "2.5.5", + "Handlebars.Net.Helpers.XPath": "2.5.5", + "Handlebars.Net.Helpers.Xeger": "2.5.5", + "Handlebars.Net.Helpers.Xslt": "2.5.5", + "JsonConverter.Newtonsoft.Json": "0.13.0", + "JsonConverter.System.Text.Json": "0.13.0", + "Microsoft.AspNetCore.Http": "2.3.9", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2", + "Stef.Validation": "0.3.0", + "WireMock.Net.Abstractions": "2.13.0" + } + }, + "WireMock.Org.Abstractions": { + "type": "Transitive", + "resolved": "2.13.0", + "contentHash": "1z7W3ryp+xufZ77ux3NODNMl/jw00Koagpj8aOaFQQgOQuxJMd8hICi1ErF1DsIXd8Ewp13s2HGntj4yWeRfRA==" + }, + "XPath2": { + "type": "Transitive", + "resolved": "1.1.5", + "contentHash": "LQg7kZyAmmb+qvv5TiOuuijxN97rRbR05qbMkVIH+i+sx9CA2UNUKGNtdVxWEXOabS8BIwlXm6ox1OOTjvZ6jw==" + }, + "XPath2.Extensions": { + "type": "Transitive", + "resolved": "1.1.5", + "contentHash": "oEbdGUJsF25QL3Vj1GgSlT2xdbxnka5dKcjuA9CouWCV/l9ecSfypOv78B1+YUD8a8w47prLNw2i3ofLNcrbGA==", + "dependencies": { + "Newtonsoft.Json": "13.0.3", + "XPath2": "1.1.5" + } + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.27.0", + "contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Ga5aA2Ca9ktz+5k3g5ukzwfexwoqwDUpV6z7atSEUvqtd6JuybU1XopHqg1oFd78QdTfZgZE9h5sHpO4qYIi5w==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "1.9.1", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.9.1", + "Microsoft.Testing.Platform": "1.9.1", + "Microsoft.Testing.Platform.MSBuild": "1.9.1", + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.inproc.console": "[3.2.2]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==", + "dependencies": { + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "O41aAzYKBT5PWqATa1oEWVNCyEUypFQ4va6K0kz37dduV3EKzXNMaV2UnEhufzU4Cce1I33gg0oldS8tGL5I0A==", + "dependencies": { + "xunit.analyzers": "1.27.0", + "xunit.v3.assert": "[3.2.2]", + "xunit.v3.core.mtp-v1": "[3.2.2]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==", + "dependencies": { + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.common": "[3.2.2]" + } + }, + "YamlDotNet": { + "type": "Transitive", + "resolved": "18.1.0", + "contentHash": "5K+9KFg2TdTl7VXv88Qzi/0lqK6JFoNP3lRuImPYGRV7K/QYklDyTrj4+A+KAki1JsQi6qKY+hDyY7d6WRqjrw==" + }, + "dodossh.client.auth": { + "type": "Project" + }, + "dodossh.crypto": { + "type": "Project", + "dependencies": { + "NSec.Cryptography": "[26.4.0, )" + } + }, + "libsodium": { + "type": "CentralTransitive", + "requested": "[1.0.22, )", + "resolved": "1.0.22", + "contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ==" + }, + "NSec.Cryptography": { + "type": "CentralTransitive", + "requested": "[26.4.0, )", + "resolved": "26.4.0", + "contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==", + "dependencies": { + "libsodium": "[1.0.22, 1.0.23)" + } + } + } + } +} \ No newline at end of file