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