Files
DodoSSH/tests/DodoSSH.Client.Session.Tests/SpelunkAccountsTests.cs
laurensandClaude Opus 5.5 57d3d3c801 Spelunk sign-in: refuse a token that is not an app session's, follow no redirects, and say every way the browser fails to come back
A token Spelunk's token endpoint returns must begin with spelunkapp_, the prefix Spelunk reads a bearer
token as an app session's by; anything else is refused as an answer this app does not understand. Neither
the sign-in's client nor the tunnel's follows redirects, so the code, its verifier and the token go to the
address signed in to and nowhere else, and signing out sends the token only to an address that still
normalises to itself, within ten seconds of its own.

Opening the browser and waiting for it now always end in a SpelunkSignInException rather than whatever
the platform threw: a browser that will not open, a phone's browser closed without an answer (the
OidcException AndroidRedirectCallback.Abandon raises, which escaped the Security page and the editor
before), another sign-in taking over the phone's redirect, and the ten-minute timeout are each said. A
refused code says it in Spelunk's own words, and a rate limit says to wait.

Tests: a refused code's detail, a non-app token, a browser that will not open, an abandoned phone sign-in,
the timeout, the user cancelling, a phone redirect sent byte for byte both times, the real loopback
listener on 127.0.0.1 taking one answer and closing, and a sign-out that will not send the token to an
address it could not have come from.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:41:11 +02:00

389 lines
16 KiB
C#

using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using DodoSSH.Client.Auth;
namespace DodoSSH.Client.Session.Tests;
/// <summary>
/// Signing this device in to Spelunk, with the browser and the redirect stood in for and Spelunk's token
/// 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.
/// </summary>
public sealed class SpelunkAccountsTests : IDisposable
{
private const string ApiBase = "https://api.spelunk.example.com";
private const string IssuedToken = "spelunkapp_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG";
private static readonly Guid SessionId = Guid.Parse("0192f0c8-eeee-7c3d-8e4f-5a6b7c8d9e0e");
private readonly ScriptedBrowser browser = new();
private readonly TokenEndpoint spelunk = new();
private static CancellationToken Token => TestContext.Current.CancellationToken;
public void Dispose() => spelunk.Dispose();
[Fact]
public async Task ASignIn_OpensSpelunksAuthorize_WithPkceAndThisDevice_AndRedeemsTheCode()
{
var signIn = await Accounts().SignInAsync(ApiBase + "/", Token);
var opened = browser.Opened.ShouldHaveSingleItem();
opened.GetLeftPart(UriPartial.Path).ShouldBe($"{ApiBase}/api/app-sign-in/authorize");
var query = Query(opened);
query["client_id"].ShouldBe("dodossh");
query["response_type"].ShouldBe("code");
query["code_challenge_method"].ShouldBe("S256");
query["device_name"].ShouldBe("work laptop");
query["redirect_uri"].ShouldBe(ScriptedCallback.Redirect);
// The verifier sent for the token is the one the challenge was made from.
using var sent = JsonDocument.Parse(spelunk.Bodies.ShouldHaveSingleItem());
var verifier = sent.RootElement.GetProperty("codeVerifier").GetString()!;
Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))).ShouldBe(query["code_challenge"]);
sent.RootElement.GetProperty("code").GetString().ShouldBe("the-code");
sent.RootElement.GetProperty("redirectUri").GetString().ShouldBe(ScriptedCallback.Redirect);
sent.RootElement.GetProperty("clientId").GetString().ShouldBe("dodossh");
signIn.ApiBase.ShouldBe(ApiBase);
signIn.Token.ShouldBe(IssuedToken);
signIn.SessionId.ShouldBe(SessionId);
signIn.Email.ShouldBe("ops@example.com");
signIn.ToString().ShouldNotContain(IssuedToken);
}
[Fact]
public async Task AnAnswerToSomeOtherSignIn_IsIgnored()
{
browser.Answer = (_, _) => new Dictionary<string, string>(StringComparer.Ordinal) { ["code"] = "the-code", ["state"] = "not-this-one" };
var refusal = await Should.ThrowAsync<SpelunkSignInException>(() => Accounts().SignInAsync(ApiBase, Token));
refusal.Message.ShouldContain("different sign-in");
spelunk.Bodies.ShouldBeEmpty("a code from another sign-in is never redeemed");
}
[Fact]
public async Task SayingNoInTheBrowser_IsSaidAsACancel()
{
browser.Answer = (_, state) => new Dictionary<string, string>(StringComparer.Ordinal) { ["error"] = "access_denied", ["state"] = state };
var refusal = await Should.ThrowAsync<SpelunkSignInException>(() => Accounts().SignInAsync(ApiBase, Token));
refusal.Message.ShouldContain("cancelled in the browser");
spelunk.Bodies.ShouldBeEmpty();
}
[Fact]
public async Task ARefusedCode_IsSaid()
{
spelunk.Status = HttpStatusCode.BadRequest;
var refusal = await Should.ThrowAsync<SpelunkSignInException>(() => Accounts().SignInAsync(ApiBase, Token));
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<SpelunkSignInException>(() => 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<SpelunkSignInException>(() => 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<SpelunkSignInException>(() => 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<SpelunkSignInException>(() => 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<SpelunkSignInException>(() => 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<OperationCanceledException>(() => 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<HttpRequestException>(() => following.Http.GetAsync(redirect, Token));
}
[Fact]
public async Task ATokenThatCouldNotBeAHeader_IsRefused()
{
spelunk.IssuedToken = "spelunkapp_with a space";
var refusal = await Should.ThrowAsync<SpelunkSignInException>(() => Accounts().SignInAsync(ApiBase, Token));
refusal.Message.ShouldContain("does not understand");
}
[Fact]
public async Task AnAddressThatWouldSendTheSignInInClear_OpensNoBrowser()
{
var refusal = await Should.ThrowAsync<SpelunkSignInException>(
() => Accounts().SignInAsync("http://api.spelunk.example.com", Token));
refusal.Message.ShouldContain("https");
browser.Opened.ShouldBeEmpty();
}
[Fact]
public async Task SigningOut_TellsSpelunk_WithTheToken_AndNeverThrows()
{
var accounts = Accounts();
var signIn = await accounts.SignInAsync(ApiBase, Token);
await accounts.SignOutAsync(signIn, Token);
var (method, uri, authorization) = spelunk.Others.ShouldHaveSingleItem();
method.ShouldBe(HttpMethod.Delete);
uri.ShouldBe(new Uri($"{ApiBase}/api/app-sign-in/session"));
authorization.ShouldBe($"Bearer {IssuedToken}");
spelunk.Unreachable = true;
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");
private static Dictionary<string, string> Query(Uri uri) =>
uri.Query.TrimStart('?').Split('&')
.Select(pair => pair.Split('=', 2))
.ToDictionary(pair => pair[0], pair => Uri.UnescapeDataString(pair[1]), StringComparer.Ordinal);
private static string Base64Url(byte[] bytes) =>
Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
/// <summary>A browser that, opened at the authorize URL, comes straight back with what the test scripts.</summary>
private sealed class ScriptedBrowser : IBrowserLauncher
{
internal List<Uri> Opened { get; } = [];
internal ScriptedCallback Callback { get; } = new();
/// <summary>The answer, from the authorize URL and the state it carried. A code and that state by default.</summary>
internal Func<Uri, string, Dictionary<string, string>> Answer { get; set; } =
(_, state) => new Dictionary<string, string>(StringComparer.Ordinal) { ["code"] = "the-code", ["state"] = state };
/// <summary>What opening the browser throws, or null when it opens.</summary>
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;
}
}
private sealed class ScriptedCallback : IAuthorizationCallback
{
internal const string Redirect = "http://127.0.0.1:53682/callback";
internal CallbackResult? Result { get; set; }
/// <summary>What the wait fails with, or null when it answers.</summary>
internal Exception? Failure { get; set; }
/// <summary>Whether the browser never comes back, so only a cancellation ends the wait.</summary>
internal bool Never { get; set; }
public Uri RedirectUri { get; set; } = new(Redirect);
public async Task<CallbackResult> 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()
{
}
}
/// <summary>
/// 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.
/// </summary>
private sealed class FollowingBrowser : IBrowserLauncher
{
internal HttpClient Http { get; } = new() { Timeout = TimeSpan.FromSeconds(5) };
internal List<Uri> Opened { get; } = [];
/// <summary>The page the listener showed once the browser came back.</summary>
internal Task<string> 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;
}
}
/// <summary>Spelunk's token and sign-out endpoints.</summary>
private sealed class TokenEndpoint : HttpMessageHandler
{
internal List<string> Bodies { get; } = [];
internal List<(HttpMethod Method, Uri Uri, string? Authorization)> Others { get; } = [];
internal HttpStatusCode Status { get; set; } = HttpStatusCode.OK;
internal string IssuedToken { get; set; } = SpelunkAccountsTests.IssuedToken;
/// <summary>The detail of a refusal, as Spelunk's problem details carry it.</summary>
internal string? ProblemDetail { get; set; }
internal bool Unreachable { get; set; }
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (Unreachable)
{
throw new HttpRequestException("Connection refused.");
}
if (request.Method != HttpMethod.Post)
{
Others.Add((request.Method, request.RequestUri!, request.Headers.Authorization?.ToString()));
return new HttpResponseMessage(HttpStatusCode.NoContent);
}
Bodies.Add(await request.Content!.ReadAsStringAsync(cancellationToken));
return new HttpResponseMessage(Status)
{
Content = new StringContent(
Status == HttpStatusCode.OK
? JsonSerializer.Serialize(new
{
token = IssuedToken,
sessionId = SessionId,
user = new { id = Guid.CreateVersion7(), email = "ops@example.com", displayName = "Ops" },
})
: JsonSerializer.Serialize(new { title = "Sign-in not accepted", detail = ProblemDetail }),
Encoding.UTF8,
"application/json"),
};
}
}
}