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:
laurensandClaude Opus 5.5 committed 2026-10-20 16:41:11 +02:00
1 parent 6f6e92f315
commit 57d3d3c801
3 files changed
+364 -61

No files matched your search

+183 -54
View File
@@ -77,6 +77,19 @@ public sealed class SpelunkAccounts : ISpelunkAccounts
/// <summary>The longest token kept. Far beyond Spelunk's; a bound on what goes into a header and the cache.</summary> /// <summary>The longest token kept. Far beyond Spelunk's; a bound on what goes into a header and the cache.</summary>
private const int MaxTokenLength = 512; private const int MaxTokenLength = 512;
/// <summary>What every token Spelunk issues a signed-in app begins with, and how Spelunk tells it from an API key.</summary>
/// <remarks>
/// 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.
/// </remarks>
internal const string TokenPrefix = "spelunkapp_";
/// <summary>The longest error body read, so a misbehaving proxy cannot hand this a gigabyte.</summary>
private const int MaxProblemBytes = 16 * 1024;
/// <summary>How long signing out waits for Spelunk before forgetting the sign-in here regardless.</summary>
private static readonly TimeSpan SignOutTimeout = TimeSpan.FromSeconds(10);
/// <summary>The page the loopback listener shows once Spelunk has sent the browser back.</summary> /// <summary>The page the loopback listener shows once Spelunk has sent the browser back.</summary>
private const string CompletionHtml = private const string CompletionHtml =
""" """
@@ -86,8 +99,12 @@ public sealed class SpelunkAccounts : ISpelunkAccounts
</body></html> </body></html>
"""; """;
/// <remarks>
/// 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.
/// </remarks>
private static readonly HttpClient SharedHttp = new( private static readonly HttpClient SharedHttp = new(
new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) }) new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5), AllowAutoRedirect = false })
{ {
Timeout = TimeSpan.FromSeconds(30), Timeout = TimeSpan.FromSeconds(30),
}; };
@@ -146,35 +163,17 @@ public sealed class SpelunkAccounts : ISpelunkAccounts
var pkce = PkcePair.Create(); var pkce = PkcePair.Create();
var state = Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(32)); 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"); 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 redirectUri = callback.RedirectUri.ToString();
var query = string.Join('&', new Dictionary<string, string>(StringComparer.Ordinal) await OpenBrowserAsync(AuthorizeUrl(normalized, redirectUri, pkce.CodeChallenge, state), cancellationToken)
{
["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)
.ConfigureAwait(false); .ConfigureAwait(false);
using var timeout = new CancellationTokenSource(BrowserTimeout, clock); var answer = await WaitForAnswerAsync(callback, cancellationToken).ConfigureAwait(false);
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.");
}
// Checked before anything else is read: an answer to some other sign-in is no answer to this one. // 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)) 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) throw new SpelunkSignInException(string.Equals(error, "access_denied", StringComparison.Ordinal)
? "Signing in to Spelunk was cancelled in the browser." ? "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) 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); return await RedeemAsync(normalized, code, pkce.CodeVerifier, redirectUri, cancellationToken).ConfigureAwait(false);
} }
/// <summary>Spelunk's authorize address for this sign-in: OAuth's own parameter names, and this device's.</summary>
private Uri AuthorizeUrl(string apiBase, string redirectUri, string codeChallenge, string state)
{
var query = string.Join('&', new Dictionary<string, string>(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);
}
}
/// <summary>Waits for the browser to come back, and turns every way it does not into a sentence.</summary>
/// <exception cref="OperationCanceledException">The user abandoned the sign-in.</exception>
private async Task<CallbackResult> 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<SpelunkSignInRecord> RedeemAsync( private async Task<SpelunkSignInRecord> RedeemAsync(
string apiBase, string code, string verifier, string redirectUri, CancellationToken cancellationToken) string apiBase, string code, string verifier, string redirectUri, CancellationToken cancellationToken)
{ {
@@ -228,49 +291,108 @@ public sealed class SpelunkAccounts : ISpelunkAccounts
{ {
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
throw new SpelunkSignInException(response.StatusCode == HttpStatusCode.BadRequest throw new SpelunkSignInException(await DescribeRefusalAsync(response, cancellationToken).ConfigureAwait(false));
? "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})."));
} }
SpelunkTokenDocument? token; return await ReadSignInAsync(apiBase, response, cancellationToken).ConfigureAwait(false);
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());
} }
} }
/// <summary>Why the token endpoint said no, in Spelunk's own words where it gave some.</summary>
private static async Task<string> 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}"),
};
}
/// <summary>Reads the token endpoint's answer, and refuses one that is not an app sign-in's.</summary>
private async Task<SpelunkSignInRecord> 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());
}
/// <inheritdoc /> /// <inheritdoc />
public async Task SignOutAsync(SpelunkSignInRecord signIn, CancellationToken cancellationToken) public async Task SignOutAsync(SpelunkSignInRecord signIn, CancellationToken cancellationToken)
{ {
ArgumentNullException.ThrowIfNull(signIn); 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 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); 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 // Unreachable, already signed out, or slow: either way this device forgets it, which is what signing
// here means. Spelunk's list under Your account still offers to end it there. // out here means. Spelunk's list under Your account still offers to end it there.
}
}
/// <summary>Spelunk's own words about a refusal, cut to one line, or null when it gave none.</summary>
private static async Task<string?> 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; } public string? DisplayName { get; set; }
} }
/// <summary>The part of an RFC 7807 refusal from Spelunk's token endpoint that is shown.</summary>
internal sealed class SpelunkSignInProblemDocument
{
public string? Detail { get; set; }
}
[JsonSourceGenerationOptions( [JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true, PropertyNameCaseInsensitive = true,
@@ -315,4 +443,5 @@ internal sealed class SpelunkTokenUserDocument
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)] UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
[JsonSerializable(typeof(SpelunkTokenRequestDocument))] [JsonSerializable(typeof(SpelunkTokenRequestDocument))]
[JsonSerializable(typeof(SpelunkTokenDocument))] [JsonSerializable(typeof(SpelunkTokenDocument))]
[JsonSerializable(typeof(SpelunkSignInProblemDocument))]
internal sealed partial class SpelunkAccountsJsonContext : JsonSerializerContext; internal sealed partial class SpelunkAccountsJsonContext : JsonSerializerContext;
+4 -2
View File
@@ -273,10 +273,12 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Bounded well short of the default hundred seconds: a ticket is one small request, and somebody /// 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.
/// </remarks> /// </remarks>
private static readonly HttpClient SharedHttp = new( private static readonly HttpClient SharedHttp = new(
new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) }) new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5), AllowAutoRedirect = false })
{ {
Timeout = TimeSpan.FromSeconds(30), Timeout = TimeSpan.FromSeconds(30),
}; };
@@ -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 /// 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. /// the token, and what is refused.
/// </summary> /// </summary>
public sealed class SpelunkAccountsTests public sealed class SpelunkAccountsTests : IDisposable
{ {
private const string ApiBase = "https://api.spelunk.example.com"; private const string ApiBase = "https://api.spelunk.example.com";
@@ -25,6 +25,8 @@ public sealed class SpelunkAccountsTests
private static CancellationToken Token => TestContext.Current.CancellationToken; private static CancellationToken Token => TestContext.Current.CancellationToken;
public void Dispose() => spelunk.Dispose();
[Fact] [Fact]
public async Task ASignIn_OpensSpelunksAuthorize_WithPkceAndThisDevice_AndRedeemsTheCode() public async Task ASignIn_OpensSpelunksAuthorize_WithPkceAndThisDevice_AndRedeemsTheCode()
{ {
@@ -86,6 +88,112 @@ public sealed class SpelunkAccountsTests
refusal.Message.ShouldContain("did not accept the sign-in"); 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] [Fact]
public async Task ATokenThatCouldNotBeAHeader_IsRefused() public async Task ATokenThatCouldNotBeAHeader_IsRefused()
{ {
@@ -123,6 +231,18 @@ public sealed class SpelunkAccountsTests
await accounts.SignOutAsync(signIn, Token); 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() => private SpelunkAccounts Accounts() =>
new(new HttpClient(spelunk), browser, _ => browser.Callback, TimeProvider.System, "work laptop"); 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; } = internal Func<Uri, string, Dictionary<string, string>> Answer { get; set; } =
(_, state) => new Dictionary<string, string>(StringComparer.Ordinal) { ["code"] = "the-code", ["state"] = state }; (_, 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) public Task OpenAsync(Uri url, CancellationToken cancellationToken)
{ {
if (Failure is { } failure)
{
throw failure;
}
Opened.Add(url); Opened.Add(url);
Callback.Result = new CallbackResult(Answer(url, Query(url)["state"])); Callback.Result = new CallbackResult(Answer(url, Query(url)["state"]));
return Task.CompletedTask; return Task.CompletedTask;
@@ -159,16 +287,57 @@ public sealed class SpelunkAccountsTests
internal CallbackResult? Result { get; set; } 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) => /// <summary>Whether the browser never comes back, so only a cancellation ends the wait.</summary>
Task.FromResult(Result ?? throw new InvalidOperationException("The browser was never opened.")); 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() 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> /// <summary>Spelunk's token and sign-out endpoints.</summary>
private sealed class TokenEndpoint : HttpMessageHandler private sealed class TokenEndpoint : HttpMessageHandler
{ {
@@ -180,6 +349,9 @@ public sealed class SpelunkAccountsTests
internal string IssuedToken { get; set; } = SpelunkAccountsTests.IssuedToken; 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; } internal bool Unreachable { get; set; }
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
@@ -207,7 +379,7 @@ public sealed class SpelunkAccountsTests
sessionId = SessionId, sessionId = SessionId,
user = new { id = Guid.CreateVersion7(), email = "ops@example.com", displayName = "Ops" }, 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, Encoding.UTF8,
"application/json"), "application/json"),
}; };