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 { 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; [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 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); } 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 }; public Task OpenAsync(Uri url, CancellationToken cancellationToken) { 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; } public Uri RedirectUri { get; } = new(Redirect); public Task WaitForCallbackAsync(string completionHtml, CancellationToken cancellationToken) => Task.FromResult(Result ?? throw new InvalidOperationException("The browser was never opened.")); public void Dispose() { } } /// 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; 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" }, }) : """{"title":"Sign-in not accepted"}""", Encoding.UTF8, "application/json"), }; } } }