Public Access
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>
This commit is contained in:
1 parent
6f6e92f315
commit
57d3d3c801
3 files changed
+364
-61
No files matched your search
@@ -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.
|
||||
/// </summary>
|
||||
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<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()
|
||||
{
|
||||
@@ -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<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;
|
||||
@@ -159,16 +287,57 @@ public sealed class SpelunkAccountsTests
|
||||
|
||||
internal CallbackResult? Result { get; set; }
|
||||
|
||||
public Uri RedirectUri { get; } = new(Redirect);
|
||||
/// <summary>What the wait fails with, or null when it answers.</summary>
|
||||
internal Exception? Failure { get; set; }
|
||||
|
||||
public Task<CallbackResult> WaitForCallbackAsync(string completionHtml, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(Result ?? throw new InvalidOperationException("The browser was never opened."));
|
||||
/// <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
|
||||
{
|
||||
@@ -180,6 +349,9 @@ public sealed class SpelunkAccountsTests
|
||||
|
||||
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)
|
||||
@@ -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"),
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user