Public Access
A Spelunk API key reaches one organisation, so a server elsewhere - oc-linux in Orange Climate - was refused before it reached the agent. Each device now signs in to a Spelunk once, under Settings -> Security -> Spelunk or from a Spelunk host's editor: the OAuth authorization code flow with PKCE for a native app (RFC 8252), with Spelunk as the authorization server. The browser opens Spelunk's /api/app-sign-in/authorize with a loopback redirect (the app's own scheme on Android), the person signs in there however they normally do and allows "DodoSSH on <device>", and the code is redeemed at /api/app-sign-in/token for an app session token. That token opens terminals in every organisation its person is in, with the role they have on each server at the time; tunnel tickets send no organisation. The sign-in stays on the device: sealed in the local cache under the LocalCacheKey (AAD resource type 18, bound to its row), never synced, wiped with the cache, and only ever sent to the Spelunk it came from. A 401 from Spelunk forgets it; SIGN OUT ends it there and here, and so does signing out of DodoSSH. Before every Spelunk connection DodoSSH asks its own identity provider whether its sign-in still stands (a forced refresh, at most once a minute), and an account it refuses is signed out of DodoSSH and of every Spelunk on the device. A route names no key any more: Spelunk hosts are written at host schema 11, which an older build shows read-only and asks to be updated for, and a payload that still names a key credential decodes with the id skipped. The SpelunkKey item type is gone: SyncEntityType 14, ChangeEntityType 14 and AAD type 17 are retired, the server drops spelunk_key and its change-log rows, and the client cache drops its cached items, outbox entries and conflicts of that type. A link still never starts a sign-in: from a Spelunk this device is not signed in to it opens the editor, which offers to. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
217 lines
8.6 KiB
C#
217 lines
8.6 KiB
C#
using System.Net;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using DodoSSH.Client.Auth;
|
|
|
|
namespace DodoSSH.Client.Session.Tests;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<string, string>(StringComparer.Ordinal) { ["code"] = "the-code", ["state"] = "not-this-one" };
|
|
|
|
var refusal = await Should.ThrowAsync<SpelunkSignInException>(() => 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<string, string>(StringComparer.Ordinal) { ["error"] = "access_denied", ["state"] = state };
|
|
|
|
var refusal = await Should.ThrowAsync<SpelunkSignInException>(() => 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<SpelunkSignInException>(() => 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<SpelunkSignInException>(() => Accounts().SignInAsync(ApiBase, Token));
|
|
|
|
refusal.Message.ShouldContain("does not understand");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AnAddressThatWouldSendTheSignInInClear_OpensNoBrowser()
|
|
{
|
|
var refusal = await Should.ThrowAsync<SpelunkSignInException>(
|
|
() => 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<string, string> 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('/', '_');
|
|
|
|
/// <summary>A browser that, opened at the authorize URL, comes straight back with what the test scripts.</summary>
|
|
private sealed class ScriptedBrowser : IBrowserLauncher
|
|
{
|
|
internal List<Uri> Opened { get; } = [];
|
|
|
|
internal ScriptedCallback Callback { get; } = new();
|
|
|
|
/// <summary>The answer, from the authorize URL and the state it carried. A code and that state by default.</summary>
|
|
internal Func<Uri, string, Dictionary<string, string>> Answer { get; set; } =
|
|
(_, state) => new Dictionary<string, string>(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<CallbackResult> WaitForCallbackAsync(string completionHtml, CancellationToken cancellationToken) =>
|
|
Task.FromResult(Result ?? throw new InvalidOperationException("The browser was never opened."));
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
}
|
|
|
|
/// <summary>Spelunk's token and sign-out endpoints.</summary>
|
|
private sealed class TokenEndpoint : HttpMessageHandler
|
|
{
|
|
internal List<string> 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<HttpResponseMessage> 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"),
|
|
};
|
|
}
|
|
}
|
|
}
|