diff --git a/src/DodoSSH.Client.Session/SpelunkAccounts.cs b/src/DodoSSH.Client.Session/SpelunkAccounts.cs index 6ded166..90217e5 100644 --- a/src/DodoSSH.Client.Session/SpelunkAccounts.cs +++ b/src/DodoSSH.Client.Session/SpelunkAccounts.cs @@ -77,6 +77,19 @@ public sealed class SpelunkAccounts : ISpelunkAccounts /// The longest token kept. Far beyond Spelunk's; a bound on what goes into a header and the cache. private const int MaxTokenLength = 512; + /// What every token Spelunk issues a signed-in app begins with, and how Spelunk tells it from an API key. + /// + /// Checked rather than assumed: a token without it is one Spelunk would read as something else, so an answer + /// carrying one is not Spelunk's app sign-in, whatever else it looks like. + /// + internal const string TokenPrefix = "spelunkapp_"; + + /// The longest error body read, so a misbehaving proxy cannot hand this a gigabyte. + private const int MaxProblemBytes = 16 * 1024; + + /// How long signing out waits for Spelunk before forgetting the sign-in here regardless. + private static readonly TimeSpan SignOutTimeout = TimeSpan.FromSeconds(10); + /// The page the loopback listener shows once Spelunk has sent the browser back. private const string CompletionHtml = """ @@ -86,8 +99,12 @@ public sealed class SpelunkAccounts : ISpelunkAccounts """; + /// + /// Redirects are not followed. The code, its verifier and the token go to the address the user signed in + /// to and nowhere else, and a Spelunk that answered with a redirect would be asking for them to be sent on. + /// private static readonly HttpClient SharedHttp = new( - new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) }) + new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5), AllowAutoRedirect = false }) { Timeout = TimeSpan.FromSeconds(30), }; @@ -146,35 +163,17 @@ public sealed class SpelunkAccounts : ISpelunkAccounts var pkce = PkcePair.Create(); var state = Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(32)); + // Disposed on the way out whatever happens, so the loopback port is closed once it has had its one + // answer - or none. using var callback = callbackFactory("/callback"); + + // Read once and sent twice, byte for byte: Spelunk compares the token request's against the authorize one. var redirectUri = callback.RedirectUri.ToString(); - var query = string.Join('&', new Dictionary(StringComparer.Ordinal) - { - ["client_id"] = ClientId, - ["redirect_uri"] = redirectUri, - ["response_type"] = "code", - ["code_challenge"] = pkce.CodeChallenge, - ["code_challenge_method"] = PkcePair.Method, - ["state"] = state, - ["device_name"] = deviceName, - }.Select(pair => $"{pair.Key}={Uri.EscapeDataString(pair.Value)}")); - - await browser.OpenAsync(new Uri($"{normalized}/api/app-sign-in/authorize?{query}", UriKind.Absolute), cancellationToken) + await OpenBrowserAsync(AuthorizeUrl(normalized, redirectUri, pkce.CodeChallenge, state), cancellationToken) .ConfigureAwait(false); - using var timeout = new CancellationTokenSource(BrowserTimeout, clock); - using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token); - - CallbackResult answer; - try - { - answer = await callback.WaitForCallbackAsync(CompletionHtml, linked.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) - { - throw new SpelunkSignInException("Spelunk did not send the browser back in time. Try signing in again."); - } + var answer = await WaitForAnswerAsync(callback, cancellationToken).ConfigureAwait(false); // Checked before anything else is read: an answer to some other sign-in is no answer to this one. if (!string.Equals(answer.State, state, StringComparison.Ordinal)) @@ -186,7 +185,7 @@ public sealed class SpelunkAccounts : ISpelunkAccounts { throw new SpelunkSignInException(string.Equals(error, "access_denied", StringComparison.Ordinal) ? "Signing in to Spelunk was cancelled in the browser." - : $"Spelunk refused the sign-in ({answer.ErrorDescription ?? error})."); + : $"Spelunk refused the sign-in ({SpelunkTunnelRoute.Readable(answer.ErrorDescription) ?? SpelunkTunnelRoute.Readable(error)})."); } if (answer.Code is not { Length: > 0 } code) @@ -197,6 +196,70 @@ public sealed class SpelunkAccounts : ISpelunkAccounts return await RedeemAsync(normalized, code, pkce.CodeVerifier, redirectUri, cancellationToken).ConfigureAwait(false); } + /// Spelunk's authorize address for this sign-in: OAuth's own parameter names, and this device's. + private Uri AuthorizeUrl(string apiBase, string redirectUri, string codeChallenge, string state) + { + var query = string.Join('&', new Dictionary(StringComparer.Ordinal) + { + ["client_id"] = ClientId, + ["redirect_uri"] = redirectUri, + ["response_type"] = "code", + ["code_challenge"] = codeChallenge, + ["code_challenge_method"] = PkcePair.Method, + ["state"] = state, + ["device_name"] = deviceName, + }.Select(pair => $"{pair.Key}={Uri.EscapeDataString(pair.Value)}")); + + return new Uri($"{apiBase}/api/app-sign-in/authorize?{query}", UriKind.Absolute); + } + + private async Task OpenBrowserAsync(Uri authorize, CancellationToken cancellationToken) + { + try + { + await browser.OpenAsync(authorize, cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) when (exception is not (OperationCanceledException or OutOfMemoryException)) + { + throw new SpelunkSignInException($"The browser could not be opened to sign in to Spelunk: {exception.Message}", exception); + } + } + + /// Waits for the browser to come back, and turns every way it does not into a sentence. + /// The user abandoned the sign-in. + private async Task WaitForAnswerAsync(IAuthorizationCallback callback, CancellationToken cancellationToken) + { + using var timeout = new CancellationTokenSource(BrowserTimeout, clock); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token); + + try + { + return await callback.WaitForCallbackAsync(CompletionHtml, linked.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + throw new SpelunkSignInException("Spelunk did not send the browser back in time. Try signing in again."); + } + catch (OperationCanceledException exception) + { + // Neither the user nor the clock: a phone's redirect is handed to one sign-in at a time, and another + // one - to DodoSSH, or to Spelunk again - took it over. + throw new SpelunkSignInException("Another sign-in started before this one finished. Try signing in again.", exception); + } + catch (Exception exception) when (exception is not OutOfMemoryException) + { + // A phone's browser closing without an answer arrives as an OidcException with the reason in it; a + // desktop's listener failing to read the browser's request arrives as whatever the socket threw. + throw new SpelunkSignInException( + exception is OidcException ? exception.Message : $"The browser's answer from Spelunk could not be read: {exception.Message}", + exception); + } + } + private async Task RedeemAsync( string apiBase, string code, string verifier, string redirectUri, CancellationToken cancellationToken) { @@ -228,49 +291,108 @@ public sealed class SpelunkAccounts : ISpelunkAccounts { if (!response.IsSuccessStatusCode) { - throw new SpelunkSignInException(response.StatusCode == HttpStatusCode.BadRequest - ? "Spelunk did not accept the sign-in. It may have taken too long; try again." - : string.Create(CultureInfo.InvariantCulture, $"Spelunk would not finish the sign-in (HTTP {(int)response.StatusCode}).")); + throw new SpelunkSignInException(await DescribeRefusalAsync(response, cancellationToken).ConfigureAwait(false)); } - SpelunkTokenDocument? token; - try - { - token = await response.Content - .ReadFromJsonAsync(SpelunkAccountsJsonContext.Default.SpelunkTokenDocument, cancellationToken) - .ConfigureAwait(false); - } - catch (JsonException exception) - { - throw new SpelunkSignInException(Unintelligible, exception); - } - - // The token goes into a header on every ticket request, so it has to be one. - if (token is not { Token: { Length: > 0 and <= MaxTokenLength } value, SessionId: { } sessionId, User: { Email: { } email } user } - || !value.All(character => char.IsAsciiLetterOrDigit(character) || character == '_')) - { - throw new SpelunkSignInException(Unintelligible); - } - - return new SpelunkSignInRecord(apiBase, value, sessionId, email, user.DisplayName ?? email, clock.GetUtcNow()); + return await ReadSignInAsync(apiBase, response, cancellationToken).ConfigureAwait(false); } } + /// Why the token endpoint said no, in Spelunk's own words where it gave some. + private static async Task DescribeRefusalAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + var detail = await ReadProblemDetailAsync(response, cancellationToken).ConfigureAwait(false); + + return response.StatusCode switch + { + // Spelunk says which: a code that was wrong, used or expired, or sessions ended meanwhile. + HttpStatusCode.BadRequest => "Spelunk did not accept the sign-in" + + (detail is null ? ". It may have taken too long; try again." : $": {detail}"), + + HttpStatusCode.TooManyRequests => + "Spelunk is refusing sign-ins from this address for a moment. Wait a minute, then try again.", + + _ => string.Create(CultureInfo.InvariantCulture, $"Spelunk would not finish the sign-in (HTTP {(int)response.StatusCode})") + + (detail is null ? "." : $": {detail}"), + }; + } + + /// Reads the token endpoint's answer, and refuses one that is not an app sign-in's. + private async Task ReadSignInAsync(string apiBase, HttpResponseMessage response, CancellationToken cancellationToken) + { + SpelunkTokenDocument? token; + try + { + token = await response.Content + .ReadFromJsonAsync(SpelunkAccountsJsonContext.Default.SpelunkTokenDocument, cancellationToken) + .ConfigureAwait(false); + } + catch (JsonException exception) + { + throw new SpelunkSignInException(Unintelligible, exception); + } + + // The token goes into a header on every ticket request, so it has to be one - and one Spelunk reads as + // an app's. + if (token is not { Token: { Length: > 0 and <= MaxTokenLength } value, SessionId: { } sessionId, User: { Email: { } email } user } + || !value.StartsWith(TokenPrefix, StringComparison.Ordinal) + || value.Length == TokenPrefix.Length + || !value.All(character => char.IsAsciiLetterOrDigit(character) || character == '_')) + { + throw new SpelunkSignInException(Unintelligible); + } + + return new SpelunkSignInRecord(apiBase, value, sessionId, email, user.DisplayName ?? email, clock.GetUtcNow()); + } + /// public async Task SignOutAsync(SpelunkSignInRecord signIn, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(signIn); + // Only to an address a sign-in could have come from: the cache is this device's own, but a record that + // no longer normalises to itself is one to forget rather than to send a token on behalf of. + if (!SpelunkRoute.TryNormalizeApiBase(signIn.ApiBase, out var apiBase, out _) + || !string.Equals(apiBase, signIn.ApiBase, StringComparison.Ordinal)) + { + return; + } + + // Bounded on its own: signing out of DodoSSH waits for this, and a Spelunk that does not answer must not + // hold that up for the transport's whole timeout. + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(SignOutTimeout); + try { - using var request = new HttpRequestMessage(HttpMethod.Delete, new Uri($"{signIn.ApiBase}/api/app-sign-in/session", UriKind.Absolute)); + using var request = new HttpRequestMessage(HttpMethod.Delete, new Uri($"{apiBase}/api/app-sign-in/session", UriKind.Absolute)); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", signIn.Token); - using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false); + using var response = await http.SendAsync(request, timeout.Token).ConfigureAwait(false); } - catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException or InvalidOperationException) + catch (Exception exception) when (exception is HttpRequestException or OperationCanceledException or InvalidOperationException or FormatException) { - // Unreachable, or already signed out: either way this device forgets it, which is what signing out - // here means. Spelunk's list under Your account still offers to end it there. + // Unreachable, already signed out, or slow: either way this device forgets it, which is what signing + // out here means. Spelunk's list under Your account still offers to end it there. + } + } + + /// Spelunk's own words about a refusal, cut to one line, or null when it gave none. + private static async Task ReadProblemDetailAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + try + { + var body = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); + if (body.Length is 0 or > MaxProblemBytes) + { + return null; + } + + var problem = JsonSerializer.Deserialize(body, SpelunkAccountsJsonContext.Default.SpelunkSignInProblemDocument); + return SpelunkTunnelRoute.Readable(problem?.Detail); + } + catch (Exception exception) when (exception is JsonException or HttpRequestException) + { + return null; } } @@ -308,6 +430,12 @@ internal sealed class SpelunkTokenUserDocument public string? DisplayName { get; set; } } +/// The part of an RFC 7807 refusal from Spelunk's token endpoint that is shown. +internal sealed class SpelunkSignInProblemDocument +{ + public string? Detail { get; set; } +} + [JsonSourceGenerationOptions( PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true, @@ -315,4 +443,5 @@ internal sealed class SpelunkTokenUserDocument UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)] [JsonSerializable(typeof(SpelunkTokenRequestDocument))] [JsonSerializable(typeof(SpelunkTokenDocument))] +[JsonSerializable(typeof(SpelunkSignInProblemDocument))] internal sealed partial class SpelunkAccountsJsonContext : JsonSerializerContext; diff --git a/src/DodoSSH.Client.Session/SpelunkTunnel.cs b/src/DodoSSH.Client.Session/SpelunkTunnel.cs index fe7140f..13635cc 100644 --- a/src/DodoSSH.Client.Session/SpelunkTunnel.cs +++ b/src/DodoSSH.Client.Session/SpelunkTunnel.cs @@ -273,10 +273,12 @@ public sealed class SpelunkTunnel : ISpelunkTunnel /// /// /// Bounded well short of the default hundred seconds: a ticket is one small request, and somebody - /// watching "Connecting…" should hear that Spelunk is not answering while they still care. + /// watching "Connecting…" should hear that Spelunk is not answering while they still care. Redirects are + /// not followed: the sign-in's token goes to the Spelunk it came from, and a redirect is a request to send + /// the ticket request somewhere else. /// private static readonly HttpClient SharedHttp = new( - new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) }) + new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5), AllowAutoRedirect = false }) { Timeout = TimeSpan.FromSeconds(30), }; diff --git a/tests/DodoSSH.Client.Session.Tests/SpelunkAccountsTests.cs b/tests/DodoSSH.Client.Session.Tests/SpelunkAccountsTests.cs index 9208970..56fd0fe 100644 --- a/tests/DodoSSH.Client.Session.Tests/SpelunkAccountsTests.cs +++ b/tests/DodoSSH.Client.Session.Tests/SpelunkAccountsTests.cs @@ -11,7 +11,7 @@ namespace DodoSSH.Client.Session.Tests; /// endpoint answered in-process: what is opened, what is checked when the browser comes back, what is sent for /// the token, and what is refused. /// -public sealed class SpelunkAccountsTests +public sealed class SpelunkAccountsTests : IDisposable { private const string ApiBase = "https://api.spelunk.example.com"; @@ -25,6 +25,8 @@ public sealed class SpelunkAccountsTests private static CancellationToken Token => TestContext.Current.CancellationToken; + public void Dispose() => spelunk.Dispose(); + [Fact] public async Task ASignIn_OpensSpelunksAuthorize_WithPkceAndThisDevice_AndRedeemsTheCode() { @@ -86,6 +88,112 @@ public sealed class SpelunkAccountsTests refusal.Message.ShouldContain("did not accept the sign-in"); } + [Fact] + public async Task ARefusedCode_IsSaidInSpelunksOwnWords() + { + spelunk.Status = HttpStatusCode.BadRequest; + spelunk.ProblemDetail = "The code is wrong, used or expired. Sign in again from the app."; + + var refusal = await Should.ThrowAsync(() => Accounts().SignInAsync(ApiBase, Token)); + + refusal.Message.ShouldContain("used or expired"); + } + + [Fact] + public async Task ATokenThatIsNotAnAppSignIns_IsRefused() + { + // Spelunk reads a bearer token by its prefix: without this one it is an API key or a JWT to it, and an + // answer carrying one is not Spelunk's app sign-in. + spelunk.IssuedToken = "spelunk_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG"; + + var refusal = await Should.ThrowAsync(() => Accounts().SignInAsync(ApiBase, Token)); + + refusal.Message.ShouldContain("does not understand"); + } + + [Fact] + public async Task ABrowserThatWillNotOpen_IsSaid() + { + browser.Failure = new InvalidOperationException("No browser is installed."); + + var refusal = await Should.ThrowAsync(() => Accounts().SignInAsync(ApiBase, Token)); + + refusal.Message.ShouldContain("No browser is installed."); + spelunk.Bodies.ShouldBeEmpty(); + } + + [Fact] + public async Task APhoneBrowserClosedWithoutAnAnswer_IsSaid_AsTheReasonThePhoneGave() + { + // What AndroidRedirectCallback.Abandon fails the wait with when the app comes back with no redirect. + browser.Callback.Failure = new OidcException("Sign-in was cancelled: the browser closed without signing you in."); + + var refusal = await Should.ThrowAsync(() => Accounts().SignInAsync(ApiBase, Token)); + + refusal.Message.ShouldContain("browser closed without signing you in"); + spelunk.Bodies.ShouldBeEmpty(); + } + + [Fact] + public async Task ABrowserThatNeverComesBack_TimesOut_AndSaysSo() + { + browser.Callback.Never = true; + + var accounts = new SpelunkAccounts(new HttpClient(spelunk), browser, _ => browser.Callback, TimeProvider.System, "work laptop") + { + BrowserTimeout = TimeSpan.FromMilliseconds(50), + }; + var refusal = await Should.ThrowAsync(() => accounts.SignInAsync(ApiBase, Token)); + + refusal.Message.ShouldContain("in time"); + } + + [Fact] + public async Task ClosingTheDialog_CancelsTheSignIn_AndRedeemsNothing() + { + browser.Callback.Never = true; + using var closed = CancellationTokenSource.CreateLinkedTokenSource(Token); + closed.CancelAfter(TimeSpan.FromMilliseconds(50)); + + await Should.ThrowAsync(() => Accounts().SignInAsync(ApiBase, closed.Token)); + + spelunk.Bodies.ShouldBeEmpty(); + } + + [Fact] + public async Task APhonesRedirect_IsSentExactlyAsSpelunkAllowsIt_BothTimes() + { + // Spelunk matches an app-scheme redirect byte for byte, at authorize and again at the token request. + browser.Callback.RedirectUri = new Uri("dev.dodotech.dodossh:/callback"); + + await Accounts().SignInAsync(ApiBase, Token); + + Query(browser.Opened.ShouldHaveSingleItem())["redirect_uri"].ShouldBe("dev.dodotech.dodossh:/callback"); + using var sent = JsonDocument.Parse(spelunk.Bodies.ShouldHaveSingleItem()); + sent.RootElement.GetProperty("redirectUri").GetString().ShouldBe("dev.dodotech.dodossh:/callback"); + } + + [Fact] + public async Task OnADesktop_TheBrowserComesBackToALoopbackLiteral_AndTheListenerTakesOneAnswer() + { + // The real listener, and a "browser" that follows Spelunk's redirect the way a browser would. Spelunk + // allows plain HTTP to a loopback IP literal only - never localhost - on any port. + var following = new FollowingBrowser(); + var accounts = new SpelunkAccounts(new HttpClient(spelunk), following, deviceName: "work laptop"); + + var signIn = await accounts.SignInAsync(ApiBase, Token); + + var redirect = new Uri(Query(following.Opened.ShouldHaveSingleItem())["redirect_uri"]); + redirect.Scheme.ShouldBe("http"); + redirect.Host.ShouldBe("127.0.0.1"); + redirect.AbsolutePath.ShouldBe("/callback"); + (await following.Page).ShouldContain("return to DodoSSH"); + signIn.Token.ShouldBe(IssuedToken); + + // Closed once it has its answer: nothing is listening there any more. + await Should.ThrowAsync(() => following.Http.GetAsync(redirect, Token)); + } + [Fact] public async Task ATokenThatCouldNotBeAHeader_IsRefused() { @@ -123,6 +231,18 @@ public sealed class SpelunkAccountsTests await accounts.SignOutAsync(signIn, Token); } + [Fact] + public async Task SigningOut_SendsTheTokenOnlyToTheAddressItCameFrom() + { + var accounts = Accounts(); + var signIn = await accounts.SignInAsync(ApiBase, Token); + + // A damaged record whose address is no longer one a sign-in could have come from is forgotten unsent. + await accounts.SignOutAsync(signIn with { ApiBase = "http://spelunk.example.com" }, Token); + + spelunk.Others.ShouldBeEmpty(); + } + private SpelunkAccounts Accounts() => new(new HttpClient(spelunk), browser, _ => browser.Callback, TimeProvider.System, "work laptop"); @@ -145,8 +265,16 @@ public sealed class SpelunkAccountsTests internal Func> Answer { get; set; } = (_, state) => new Dictionary(StringComparer.Ordinal) { ["code"] = "the-code", ["state"] = state }; + /// What opening the browser throws, or null when it opens. + internal Exception? Failure { get; set; } + public Task OpenAsync(Uri url, CancellationToken cancellationToken) { + if (Failure is { } failure) + { + throw failure; + } + Opened.Add(url); Callback.Result = new CallbackResult(Answer(url, Query(url)["state"])); return Task.CompletedTask; @@ -159,16 +287,57 @@ public sealed class SpelunkAccountsTests internal CallbackResult? Result { get; set; } - public Uri RedirectUri { get; } = new(Redirect); + /// What the wait fails with, or null when it answers. + internal Exception? Failure { get; set; } - public Task WaitForCallbackAsync(string completionHtml, CancellationToken cancellationToken) => - Task.FromResult(Result ?? throw new InvalidOperationException("The browser was never opened.")); + /// Whether the browser never comes back, so only a cancellation ends the wait. + internal bool Never { get; set; } + + public Uri RedirectUri { get; set; } = new(Redirect); + + public async Task WaitForCallbackAsync(string completionHtml, CancellationToken cancellationToken) + { + if (Never) + { + await Task.Delay(Timeout.Infinite, cancellationToken); + } + + return Failure is { } failure + ? throw failure + : Result ?? throw new InvalidOperationException("The browser was never opened."); + } public void Dispose() { } } + /// + /// A browser that does what Spelunk's consent page ends with: goes to the redirect URI with a code and the + /// state it was opened with. + /// + private sealed class FollowingBrowser : IBrowserLauncher + { + internal HttpClient Http { get; } = new() { Timeout = TimeSpan.FromSeconds(5) }; + + internal List Opened { get; } = []; + + /// The page the listener showed once the browser came back. + internal Task Page { get; private set; } = Task.FromResult(string.Empty); + + public async Task OpenAsync(Uri url, CancellationToken cancellationToken) + { + Opened.Add(url); + var query = Query(url); + + // Not awaited by the sign-in, as a real browser is not: it is answered once the listener is waiting. + var back = new Uri($"{query["redirect_uri"]}?code=the-code&state={Uri.EscapeDataString(query["state"])}"); + Page = Task.Run(() => Http.GetStringAsync(back, cancellationToken), cancellationToken); + + await Task.CompletedTask; + } + } + /// Spelunk's token and sign-out endpoints. private sealed class TokenEndpoint : HttpMessageHandler { @@ -180,6 +349,9 @@ public sealed class SpelunkAccountsTests internal string IssuedToken { get; set; } = SpelunkAccountsTests.IssuedToken; + /// The detail of a refusal, as Spelunk's problem details carry it. + internal string? ProblemDetail { get; set; } + internal bool Unreachable { get; set; } protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) @@ -207,7 +379,7 @@ public sealed class SpelunkAccountsTests sessionId = SessionId, user = new { id = Guid.CreateVersion7(), email = "ops@example.com", displayName = "Ops" }, }) - : """{"title":"Sign-in not accepted"}""", + : JsonSerializer.Serialize(new { title = "Sign-in not accepted", detail = ProblemDetail }), Encoding.UTF8, "application/json"), };