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"), }; } } }