Public Access
Signing in failed at the token exchange with `400 Offline tokens not allowed
for the user or client`. A user declared in a realm import gets no role
mappings at all unless realmRoles lists them — not even the realm's own
default-roles composite, which Keycloak grants automatically to a user created
through the admin API or the registration form. alice and bob had none, and
offline_access lives inside that composite, which the desktop client requests.
Verified against the running Keycloak: alice's role-mappings were {} before and
resolve to default-roles-dodossh, offline_access, uma_authorization after.
The authorization request succeeds and the failure lands one step later, at the
code redemption, which makes it read like a client bug. It is not.
The E2E suite could not catch this because it created its own account through
the admin API — exercising a provisioning path no real user takes, and passing
while the account the README tells you to use could not sign in at all. It now
signs in as the realm's own alice, which is sound because the Keycloak and
PostgreSQL containers are per-run so the account is pristine, and this assembly
holds one test. Removing the roles again fails it with exactly the reported
message; that is what makes the coverage real rather than nominal.
Two traps recorded in docs/platform-flags.md, the second found by shipping it
for a moment: Keycloak's RealmRepresentation deserialises with
FAIL_ON_UNKNOWN_PROPERTIES enabled, so the "_comment" key I first used to
explain the roles inside the JSON did not get ignored — the import threw and
the container refused to start. Explanations go in the docs, not in the realm
file.
335 lines
13 KiB
C#
335 lines
13 KiB
C#
using System.Text;
|
|
using DodoSSH.Client.Domain;
|
|
using DodoSSH.Client.Session;
|
|
using DodoSSH.Client.Ssh;
|
|
using DodoSSH.Client.Storage;
|
|
using DodoSSH.Contracts;
|
|
using DodoSSH.Crypto;
|
|
|
|
namespace DodoSSH.SystemTests;
|
|
|
|
/// <summary>
|
|
/// M1's definition of done: sign in, enroll, unlock, create a host, sync, read it on a second machine,
|
|
/// and open a shell on it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Nothing is stubbed. A real Keycloak issues the tokens and signs the key binding, a real API stores the
|
|
/// ciphertext in a real PostgreSQL, real DSH1 crypto seals and opens it, and a real <c>sshd</c> answers at
|
|
/// the end. Every other suite substitutes at least one of those, and each substitution is a place where a
|
|
/// misreading of the protocol can be consistent on both sides and still wrong in production — which is
|
|
/// exactly what this found the first time it ran.
|
|
/// </para>
|
|
/// <para>
|
|
/// One test rather than several, because the steps are not independent: you cannot unlock without having
|
|
/// enrolled, and enrollment happens once per account. Splitting them would mean sharing mutable state
|
|
/// between tests or repeating a minute of setup per assertion.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class M1VerticalSliceTests(DevStack stack) : IClassFixture<DevStack>, IAsyncDisposable
|
|
{
|
|
private const string Passphrase = "an end to end passphrase";
|
|
|
|
/// <remarks>
|
|
/// 64 MiB is the floor <c>EnrollmentLimits</c> enforces, and this suite has to respect it — the other
|
|
/// client suites use 8 MiB because their in-memory servers have no policy, and a real one rejects that
|
|
/// outright. Worth knowing rather than discovering: the reduction those suites take for speed is only
|
|
/// available because nothing is checking, and the difference is a 400 rather than a slow test.
|
|
/// </remarks>
|
|
private static readonly Argon2Profile ServerFloorProfile =
|
|
Argon2Profile.FromStoredParameters(memoryKibibytes: 64 * 1024, passes: 3, parallelism: 1);
|
|
|
|
private readonly List<string> directories = [];
|
|
|
|
/// <inheritdoc />
|
|
public ValueTask DisposeAsync()
|
|
{
|
|
foreach (var directory in directories.Where(Directory.Exists))
|
|
{
|
|
Directory.Delete(directory, recursive: true);
|
|
}
|
|
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TheWholeSlice()
|
|
{
|
|
// The realm file's own account, deliberately — see DevStack.RealmUser. A runtime-minted one hid a
|
|
// sign-in failure that only the committed configuration had.
|
|
var account = DevStack.RealmUser;
|
|
var browser = new ScriptedBrowser(account.Username, account.Password);
|
|
|
|
using var connection = await ServerConnection
|
|
.SignInAsync(stack.ApiBaseUrl, browser, TimeProvider.System, Token);
|
|
|
|
AssertDiscoveredFromTheServer(connection);
|
|
|
|
using var laptopCache = await OpenCacheAsync();
|
|
await EnrollAsync(connection, laptopCache, browser);
|
|
|
|
var laptop = await UnlockAsync(laptopCache);
|
|
await using var laptopSession = laptop;
|
|
|
|
var host = BuildHost();
|
|
var entityId = await laptop.Hosts.CreateAsync(laptop.ActiveVaultId, host, Token);
|
|
|
|
var pushed = await laptop.SyncAsync(connection.Sync, Token);
|
|
pushed.Pushed.ShouldBe(1);
|
|
pushed.NeedsAttention.ShouldBeFalse();
|
|
|
|
await AssertTheServerCannotSeeTheAddressAsync(connection, entityId);
|
|
|
|
var seen = await ReadOnASecondMachineAsync(connection, host, entityId);
|
|
|
|
await AssertUnlocksOfflineAsync(laptopCache);
|
|
|
|
await OpenAShellAsync(seen);
|
|
}
|
|
|
|
// ---- Steps ----
|
|
|
|
/// <remarks>
|
|
/// The user typed one server URL. Everything about the identity provider — the authority, the client
|
|
/// id, the scopes — came back from the server, which is the whole onboarding story.
|
|
/// </remarks>
|
|
private void AssertDiscoveredFromTheServer(ServerConnection connection)
|
|
{
|
|
connection.Configuration.Oidc.Authority.ToString()
|
|
.ShouldStartWith(stack.Authority.ToString());
|
|
|
|
connection.Configuration.Oidc.ClientId.ShouldBe("dodossh-desktop");
|
|
|
|
// Server:PublicBaseUrl, which is what a client behind a proxy would follow. Worth asserting
|
|
// because it is configuration the server states about itself and nothing else would notice it
|
|
// being wrong.
|
|
connection.Configuration.ApiBaseUrl.ShouldBe(stack.ApiBaseUrl);
|
|
|
|
connection.Meta.SyncProtocolVersion.ShouldBe(1);
|
|
connection.Meta.CryptoSpecVersion.ShouldBe(1);
|
|
}
|
|
|
|
private async Task EnrollAsync(
|
|
ServerConnection connection,
|
|
ClientCacheFactory caches,
|
|
ScriptedBrowser browser)
|
|
{
|
|
var provisioner = new AccountProvisioner(
|
|
connection.Account, connection.KeyBinding, caches, TimeProvider.System, ServerFloorProfile);
|
|
|
|
var before = await provisioner.RefreshAsync(ServerUrl, Token);
|
|
before.Status.ShouldBe(ProvisionStatus.EnrollmentRequired);
|
|
|
|
var enrolled = await provisioner.EnrollAsync(
|
|
ServerUrl, Passphrase, "e2e-laptop", "Personal", Token);
|
|
|
|
enrolled.Status.ShouldBe(ProvisionStatus.Ready);
|
|
enrolled.RecoveryCode.ShouldNotBeNullOrWhiteSpace();
|
|
|
|
// Two sign-ins, not one. The second is the identity-provider key binding: an authorization whose
|
|
// nonce is the key statement's hash, whose ID token the server verified against Keycloak's JWKS
|
|
// before accepting the key. That is what stops a compromised DodoSSH server fabricating a key for
|
|
// someone who never enrolled — see ADR 0001 — and it is invisible unless something counts.
|
|
browser.SignInCount.ShouldBe(
|
|
2, "enrollment must obtain an identity-provider signature over the published key");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Asserted against what the server hands back, not against the local mirror. With relay off the
|
|
/// address stays inside the ciphertext; ADR 0004 is the only reason it would ever be otherwise.
|
|
/// </remarks>
|
|
private static async Task AssertTheServerCannotSeeTheAddressAsync(
|
|
ServerConnection connection,
|
|
Guid entityId)
|
|
{
|
|
var vaultId = (await connection.Account.GetMeAsync(Token)).Vaults.Single().VaultId;
|
|
|
|
var page = await connection.Sync.SyncPullAsync(
|
|
vaultId, new SyncPullRequest(null, 100, [SyncEntityType.Host]), Token);
|
|
|
|
var change = page.Changes.Single(c => c.EntityId == entityId);
|
|
|
|
change.PlaintextFields.ShouldNotBeNull();
|
|
change.PlaintextFields.RelayEnabled.ShouldBeFalse();
|
|
change.PlaintextFields.Hostname.ShouldBeNull("the address must not leave the payload");
|
|
change.PlaintextFields.Port.ShouldBeNull();
|
|
|
|
// What it does hold is opaque, and it carries its data key as the specification requires.
|
|
change.Payload.ShouldNotBeNull();
|
|
change.Payload.WrappedDataKey.ShouldNotBeEmpty();
|
|
change.Payload.DataKeyId.ShouldNotBe(Guid.Empty);
|
|
}
|
|
|
|
private async Task<HostSecret> ReadOnASecondMachineAsync(
|
|
ServerConnection connection,
|
|
HostSecret expected,
|
|
Guid entityId)
|
|
{
|
|
using var desktopCache = await OpenCacheAsync();
|
|
|
|
var provisioner = new AccountProvisioner(
|
|
connection.Account, connection.KeyBinding, desktopCache, TimeProvider.System, ServerFloorProfile);
|
|
|
|
// Already enrolled, so this only caches what an offline unlock will need.
|
|
(await provisioner.RefreshAsync(ServerUrl, Token)).Status
|
|
.ShouldBe(ProvisionStatus.Ready);
|
|
|
|
var desktop = await UnlockAsync(desktopCache);
|
|
await using var session = desktop;
|
|
|
|
var pulled = await desktop.SyncAsync(connection.Sync, Token);
|
|
pulled.Pulled.ShouldBe(1);
|
|
|
|
var listing = await desktop.Hosts.ListAsync(desktop.ActiveVaultId, Token);
|
|
var seen = listing.Hosts.ShouldHaveSingleItem();
|
|
|
|
seen.EntityId.ShouldBe(entityId);
|
|
seen.HasUnsyncedChanges.ShouldBeFalse();
|
|
|
|
// The decrypted host survived a round trip through a server that could read none of it — including
|
|
// the directives, which merge per name and therefore have to come back in canonical form.
|
|
seen.Host.ShouldBe(expected);
|
|
|
|
return seen.Host;
|
|
}
|
|
|
|
private static async Task AssertUnlocksOfflineAsync(ClientCacheFactory caches)
|
|
{
|
|
// Nothing here touches the network: the salt, the parameters and the wrapped bundle are local.
|
|
var offline = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token);
|
|
|
|
offline.IsUnlocked.ShouldBeTrue(offline.Message);
|
|
await offline.Session!.DisposeAsync();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Goes through the real trust-on-first-use path rather than around it. An unknown host key throws, the
|
|
/// caller pins it and retries — which is what the interface does, and the only way to prove the
|
|
/// fingerprint a user would be shown is the one the server actually presented.
|
|
/// </remarks>
|
|
private static async Task OpenAShellAsync(HostSecret host)
|
|
{
|
|
var knownHosts = new InMemoryKnownHostStore();
|
|
var factory = new SshNetConnectionFactory(knownHosts);
|
|
|
|
var request = new SshConnectionRequest(
|
|
host.Hostname, host.Port, host.Username!, new SshPasswordCredential(DevStack.SshPassword));
|
|
|
|
try
|
|
{
|
|
await using var first = await factory.ConnectAsync(request, Token);
|
|
Assert.Fail("An unseen host key must not be trusted silently.");
|
|
}
|
|
catch (SshHostKeyUnknownException exception)
|
|
{
|
|
exception.Presentation.Fingerprint.ShouldStartWith("SHA256:");
|
|
await knownHosts.TrustAsync(exception.Presentation, Token);
|
|
}
|
|
|
|
await using var connection = await factory.ConnectAsync(request, Token);
|
|
await using var shell = await connection.OpenShellAsync(TerminalSize.Default, Token);
|
|
|
|
await shell.WriteTextAsync("echo dodossh-e2e-ok\n", Token);
|
|
|
|
var output = await ReadUntilEchoedAsync(shell, "dodossh-e2e-ok");
|
|
|
|
output.ShouldContain("dodossh-e2e-ok");
|
|
}
|
|
|
|
// ---- Helpers ----
|
|
|
|
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
|
|
|
/// <remarks>
|
|
/// The provisioner takes the URL as a string because it is also the cache's identity — the value an
|
|
/// offline unlock compares against to refuse a cache belonging to another server.
|
|
/// </remarks>
|
|
private string ServerUrl => stack.ApiBaseUrl.ToString();
|
|
|
|
private HostSecret BuildHost() =>
|
|
new()
|
|
{
|
|
Label = "e2e-target",
|
|
Hostname = stack.SshHostname,
|
|
Port = stack.SshHostPort,
|
|
Username = DevStack.SshUsername,
|
|
Notes = "created by the end-to-end slice",
|
|
Options = HostOptions.Create([new HostOption("ServerAliveInterval", "30")]),
|
|
};
|
|
|
|
private async Task<ClientCacheFactory> OpenCacheAsync()
|
|
{
|
|
var directory = Path.Combine(Path.GetTempPath(), $"dodossh-e2e-{Guid.CreateVersion7():N}");
|
|
Directory.CreateDirectory(directory);
|
|
directories.Add(directory);
|
|
|
|
var factory = ClientCacheFactory.ForFile(new ClientPaths(directory).CacheFile);
|
|
|
|
try
|
|
{
|
|
await factory.MigrateAsync(Token);
|
|
return factory;
|
|
}
|
|
catch
|
|
{
|
|
factory.Dispose();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private static async Task<VaultSession> UnlockAsync(ClientCacheFactory caches)
|
|
{
|
|
var outcome = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token);
|
|
|
|
outcome.IsUnlocked.ShouldBeTrue(outcome.Message);
|
|
return outcome.Session!;
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Waits for the marker twice — once as the shell echoes the typed command, once as its output — rather
|
|
/// than for a fixed time. The login banner arrives first and its length is not something this test
|
|
/// should have to know.
|
|
/// </remarks>
|
|
private static async Task<string> ReadUntilEchoedAsync(ISshShellSession shell, string marker)
|
|
{
|
|
var text = new StringBuilder();
|
|
var buffer = new byte[8192];
|
|
|
|
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(Token);
|
|
deadline.CancelAfter(TimeSpan.FromSeconds(30));
|
|
|
|
while (!deadline.IsCancellationRequested)
|
|
{
|
|
var read = await shell.ReadAsync(buffer, deadline.Token);
|
|
|
|
if (read == 0)
|
|
{
|
|
break;
|
|
}
|
|
|
|
text.Append(Encoding.UTF8.GetString(buffer, 0, read));
|
|
|
|
if (Occurrences(text.ToString(), marker) >= 2)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
return text.ToString();
|
|
}
|
|
|
|
private static int Occurrences(string text, string marker)
|
|
{
|
|
var count = 0;
|
|
var index = 0;
|
|
|
|
while ((index = text.IndexOf(marker, index, StringComparison.Ordinal)) >= 0)
|
|
{
|
|
count++;
|
|
index += marker.Length;
|
|
}
|
|
|
|
return count;
|
|
}
|
|
}
|