using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using DodoSSH.Client.Auth;
namespace DodoSSH.Client.Session.Tests;
///
/// 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.
///
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(StringComparer.Ordinal) { ["code"] = "the-code", ["state"] = "not-this-one" };
var refusal = await Should.ThrowAsync(() => 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(StringComparer.Ordinal) { ["error"] = "access_denied", ["state"] = state };
var refusal = await Should.ThrowAsync(() => 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(() => 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(() => 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()
{
spelunk.IssuedToken = "spelunkapp_with a space";
var refusal = await Should.ThrowAsync(() => Accounts().SignInAsync(ApiBase, Token));
refusal.Message.ShouldContain("does not understand");
}
[Fact]
public async Task AnAddressThatWouldSendTheSignInInClear_OpensNoBrowser()
{
var refusal = await Should.ThrowAsync(
() => 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 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('/', '_');
/// A browser that, opened at the authorize URL, comes straight back with what the test scripts.
private sealed class ScriptedBrowser : IBrowserLauncher
{
internal List Opened { get; } = [];
internal ScriptedCallback Callback { get; } = new();
/// The answer, from the authorize URL and the state it carried. A code and that state by default.
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;
}
}
private sealed class ScriptedCallback : IAuthorizationCallback
{
internal const string Redirect = "http://127.0.0.1:53682/callback";
internal CallbackResult? Result { get; set; }
/// What the wait fails with, or null when it answers.
internal Exception? Failure { get; set; }
/// 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
{
internal List 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;
/// 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)
{
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"),
};
}
}
}