diff --git a/README.md b/README.md
index 4ebf4c4..05b88d6 100644
--- a/README.md
+++ b/README.md
@@ -145,6 +145,21 @@ the next sync. If a server is legitimately rebuilt and offers a new key, the con
with no way to continue from the warning — edit the host and choose **Forget host key**, which is deliberately
somewhere you have to go on purpose.
+**Signing in once is enough.** The refresh token is kept in the local cache, sealed under the vault's own
+key, so a later launch resumes the session itself and no browser opens — and because it is sealed under that
+key, resuming can only happen *after* the vault is unlocked. A machine that unlocks with no network keeps
+trying: every synchronisation pass asks for a connection, so a laptop opened on a train is online again
+within a minute of finding a network, with nothing pressed. Unlock takes **Enter** in the passphrase box,
+and nothing about unlocking ever waits on the network.
+
+**Signing out** is under Preferences → *Account*, and again on the unlock screen, where it is the only
+answer to a forgotten passphrase — nothing can recover one. It asks first, and says what it costs: it
+empties this machine's cache (the profile, the cached items, and anything still queued to be sent) and
+withdraws this machine's device key from the account. The vault itself is on the server and is untouched, so
+signing in again brings it all back; the count in the confirmation is the one thing that exists nowhere
+else. Your session at the identity provider is *not* ended — DodoSSH has no way to end it — so on a machine
+that is not yours, sign out there too.
+
Two of M1's known gaps are visible immediately, so they are worth expecting rather than diagnosing: password
authentication asks for the password every time, because nothing in the interface can create a vault
credential yet (they do sync — there is just no editor for one); and unlock asks for the passphrase on every
diff --git a/docs/crypto.md b/docs/crypto.md
index 4ab7f77..e7f16e5 100644
--- a/docs/crypto.md
+++ b/docs/crypto.md
@@ -193,6 +193,16 @@ moment to discard it.
The label is versioned, so a client holding a v1 cache fails to open it and re-pulls rather than
decrypting to nonsense. That is the whole reason for bumping rather than reusing the label.
+**What it seals, and the one entry that is not vault content.** Three kinds of record: the plaintext
+columns the server needs, the values a merge overrode, and — added 2026-07-31 — the OIDC **refresh
+token** this machine may resume its sign-in with, bound as `LocalCache(User, userId)`. The third is
+different in kind from the other two: it is a credential for the *account*, not for the vault, and
+sealing it here is a deliberate choice about what a stolen cache file is worth. A refresh token kept in
+the clear beside the ciphertext would let a copied profile reach the server as its owner without the
+passphrase ever being guessed; under this key it can only be read by a process that has already opened
+the vault. The cost is stated rather than worked around: **a locked client cannot reach the server at
+all**, because the token it would present is behind the same lock as everything else.
+
### Why the bundle is wrapped many ways
This is the load-bearing structural choice. Because every wrap protects the *same* bundle:
diff --git a/docs/design-import-gaps.md b/docs/design-import-gaps.md
index a1dd79a..dda7657 100644
--- a/docs/design-import-gaps.md
+++ b/docs/design-import-gaps.md
@@ -153,8 +153,14 @@ Nothing on this screen exists. It is in the nav rail and reaches a screen that s
## Preferences
-The screen ships with what is real — this machine's device key, locking, and syncing — and lists the rest
-as absent rather than omitting it silently.
+The screen ships with what is real — this machine's device key, locking, syncing, and signing out — and
+lists the rest as absent rather than omitting it silently.
+
+> **Signing out is not on the design and is here anyway.** The design has no way to leave a machine, and
+> without one there is no way to hand a laptop on, to enrol a second account, or to get past a forgotten
+> passphrase — which is unrecoverable by construction, so the unlock screen would otherwise be a dead end.
+> It empties the local cache and withdraws the device key from the account; it cannot end the session at the
+> identity provider, and says so.
| Design element | Layer | What it would take |
| --- | --- | --- |
diff --git a/src/DodoSSH.Client.App/App.axaml.cs b/src/DodoSSH.Client.App/App.axaml.cs
index 1b8496a..7ad50bd 100644
--- a/src/DodoSSH.Client.App/App.axaml.cs
+++ b/src/DodoSSH.Client.App/App.axaml.cs
@@ -81,7 +81,14 @@ internal sealed partial class DodoSshApp : Application
async (url, cancellationToken) => await ServerConnection
.SignInAsync(url, browser, TimeProvider.System, cancellationToken)
.ConfigureAwait(false),
- TimeProvider.System);
+ TimeProvider.System,
+ passphraseProfile: null,
+
+ // The other half of signing in: a refresh grant, no browser, and nobody present. It is what
+ // makes a launch after the first one arrive online rather than merely enrolled.
+ resume: async (url, refreshToken, cancellationToken) => await ServerConnection
+ .ResumeAsync(url, refreshToken, TimeProvider.System, cancellationToken)
+ .ConfigureAwait(false));
desktop.MainWindow = new MainWindow { DataContext = viewModel };
diff --git a/src/DodoSSH.Client.App/ViewModels/MainWindowViewModel.cs b/src/DodoSSH.Client.App/ViewModels/MainWindowViewModel.cs
index c22a633..e0d7805 100644
--- a/src/DodoSSH.Client.App/ViewModels/MainWindowViewModel.cs
+++ b/src/DodoSSH.Client.App/ViewModels/MainWindowViewModel.cs
@@ -115,10 +115,30 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
private readonly IDeviceKeyStore deviceKeys;
private readonly SignInHandler signIn;
+
+ ///
+ /// Optional, and null is not merely "not configured": a shell with no resume handler is one that can
+ /// only be online because somebody signed in during this run, which is what every test that asserts
+ /// offline behaviour relies on.
+ ///
+ private readonly ResumeHandler? resume;
+
private readonly TimeProvider clock;
private readonly Argon2Profile? passphraseProfile;
private IVaultServer? connection;
+
+ /// The refresh token last written to the cache, so a rotation is noticed without reading it back.
+ private string? rememberedToken;
+
+ /// Guards against two resume attempts overlapping.
+ ///
+ /// A plain flag rather than a semaphore because every caller is on the UI thread — the sync loop and
+ /// the Sync button — and what has to be prevented is a second attempt starting while the first is
+ /// waiting on a token endpoint, not a data race.
+ ///
+ private bool resuming;
+
private bool disposed;
///
@@ -132,6 +152,19 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
///
internal delegate Task SignInHandler(Uri serverUrl, CancellationToken cancellationToken);
+ ///
+ /// Re-establishes a connection from a remembered sign-in, without a browser.
+ ///
+ ///
+ /// A delegate for the same reason is one: a real resume needs discovery
+ /// and a token endpoint, and making that the only way to reach this state machine would put the whole
+ /// "comes back online by itself" behaviour out of reach of a test.
+ ///
+ internal delegate Task ResumeHandler(
+ Uri serverUrl,
+ string refreshToken,
+ CancellationToken cancellationToken);
+
internal MainWindowViewModel(
ClientPaths paths,
ClientCacheFactory caches,
@@ -140,7 +173,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
IDeviceKeyStore deviceKeys,
SignInHandler signIn,
TimeProvider clock,
- Argon2Profile? passphraseProfile = null)
+ Argon2Profile? passphraseProfile = null,
+ ResumeHandler? resume = null)
{
this.paths = paths;
this.caches = caches;
@@ -148,6 +182,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
this.knownHosts = knownHosts;
this.deviceKeys = deviceKeys;
this.signIn = signIn;
+ this.resume = resume;
this.clock = clock;
this.passphraseProfile = passphraseProfile;
@@ -262,6 +297,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
internal bool IsLocked => State == ShellState.Locked;
+ /// Whether the unlock card itself is showing, rather than the confirmation over it.
+ ///
+ /// Its own property because the markup cannot express IsLocked && !IsConfirmingSignOut,
+ /// and the two cards genuinely swap rather than stack: the unlock card is already near the height the
+ /// window guarantees at its minimum size, so putting a second question underneath it would push
+ /// buttons off a screen with nothing to scroll.
+ ///
+ internal bool IsAskingForThePassphrase => IsLocked && !IsConfirmingSignOut;
+
internal bool IsUnlocked => State == ShellState.Unlocked;
/// Whether a connection to the server is currently held.
@@ -642,9 +686,23 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
AccountName = outcome.Me.DisplayName ?? outcome.Me.Email ?? outcome.Me.Subject;
StatusMessage = outcome.Message;
- State = outcome.Status == ProvisionStatus.EnrollmentRequired
- ? ShellState.NeedsEnrollment
- : ShellState.Locked;
+ if (outcome.Status == ProvisionStatus.EnrollmentRequired)
+ {
+ State = ShellState.NeedsEnrollment;
+ return;
+ }
+
+ // An unlocked vault stays unlocked. This command is reachable from the preferences screen
+ // of a running application — it is how somebody whose sign-in expired gets back online —
+ // and moving the state machine to Locked there would throw an unlock screen over an open
+ // vault whose keys are still in memory, which is neither locked nor honest.
+ if (IsUnlocked)
+ {
+ await RememberSignInAsync(cancellationToken).ConfigureAwait(true);
+ return;
+ }
+
+ State = ShellState.Locked;
}).ConfigureAwait(true);
}
@@ -884,7 +942,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
throw;
}
- Vault = new VaultViewModel(session, workspace, knownHosts, () => connection);
+ Vault = new VaultViewModel(session, workspace, knownHosts, () => connection, ReconnectAsync);
State = ShellState.Unlocked;
// Offered only where it can actually be honoured: a machine that can keep a key, and a profile that
@@ -908,9 +966,190 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// After the first load, so the list is on screen before anything talks to a server. The loop is
// started from the UI thread deliberately: every pass resumes here, which is what keeps the
// observable collections single-threaded.
+ //
+ // Its first pass is also what brings this machine online: the pass asks ReconnectAsync for a
+ // server, and that is where a remembered sign-in is resumed. Nothing here has to know whether
+ // this unlock followed a sign-in or a cold launch on a train.
+ //
+ // Deliberately not awaited here, and not done before this point either. Resuming is a discovery
+ // call and a token exchange — a network round trip, and on an unreachable network a slow one —
+ // and unlocking must never wait on one. Everything the unlock screen promises about working
+ // offline stops being true the moment the passphrase leads to a socket. So the vault opens, and
+ // the titlebar says OFFLINE until the round trip this starts has an answer.
Vault.StartAutoSync();
}
+ ///
+ /// Gets this machine online if it is not, and keeps the remembered sign-in current if it is.
+ ///
+ ///
+ ///
+ /// Handed to the vault, which asks once per synchronisation pass. That cadence is the whole design:
+ /// there is no connectivity monitor and no reconnect backoff, because a pass a minute already is one,
+ /// and a machine that comes back from a closed lid is online again within a minute of having a
+ /// network — with nothing pressed and no browser opened.
+ ///
+ ///
+ /// Resuming needs an unlocked vault, and that is deliberate rather than incidental. The
+ /// remembered refresh token is sealed under the vault's own cache key, so this can only succeed after
+ /// somebody has opened the vault — a stolen laptop yields a cache file that cannot reach the account
+ /// any more than it can read the hosts.
+ ///
+ ///
+ /// Every failure returns null and stays quiet, with one exception: a provider that refuses the
+ /// token is not a transient condition and will refuse it again once a minute forever, so that one is
+ /// said out loud and the token is dropped.
+ ///
+ ///
+ private async Task ReconnectAsync(CancellationToken cancellationToken)
+ {
+ if (connection is { } held)
+ {
+ await RememberSignInAsync(cancellationToken).ConfigureAwait(true);
+ return held;
+ }
+
+ if (resume is not { } handler || resuming || Vault is not { } vault)
+ {
+ return null;
+ }
+
+ resuming = true;
+
+ try
+ {
+ return await ResumeAsync(vault, handler, cancellationToken).ConfigureAwait(true);
+ }
+ finally
+ {
+ resuming = false;
+ }
+ }
+
+ ///
+ /// Split from only so the guard, the flag and the attempt are three short
+ /// things rather than one long one. Everything about why this behaves as it does is up there.
+ ///
+ private async Task ResumeAsync(
+ VaultViewModel vault,
+ ResumeHandler handler,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var token = await vault.Session
+ .ReadRememberedSignInAsync(cancellationToken)
+ .ConfigureAwait(true);
+
+ if (token is null
+ || !Uri.TryCreate(vault.Session.Profile.ServerUrl, UriKind.Absolute, out var server))
+ {
+ return null;
+ }
+
+ var resumed = await handler(server, token, cancellationToken).ConfigureAwait(true);
+
+ connection = resumed;
+ rememberedToken = token;
+
+ OnPropertyChanged(nameof(IsOnline));
+ RaiseSyncState();
+
+ // The refresh that just happened may have rotated the token, and the rotated one is the only
+ // one the next launch can use.
+ await RememberSignInAsync(cancellationToken).ConfigureAwait(true);
+
+ return resumed;
+ }
+ catch (OidcException exception)
+ {
+ // The provider answered and said no: the session was revoked, or the token was rotated and
+ // this machine kept the old one. Retrying costs a round trip a minute and can only ever get
+ // the same answer, so the token goes and the user is told the one thing that fixes it.
+ await ForgetSignInAsync(cancellationToken).ConfigureAwait(true);
+
+ Announce($"Your sign-in has expired, so this machine is offline: {exception.Message} "
+ + "Sign in again from Preferences to start syncing.");
+
+ return null;
+ }
+ catch (Exception exception) when (exception is not OutOfMemoryException)
+ {
+ // Everything else is a machine with no network, a server that is down, or a vault that was
+ // locked mid-attempt — all of which are ordinary and all of which resolve themselves. The
+ // titlebar already says OFFLINE; a socket error once a minute would say nothing more.
+ return null;
+ }
+ }
+
+ ///
+ /// Writes the connection's current refresh token into the vault, if it has changed.
+ ///
+ ///
+ /// Called on every pass rather than driven by an event, because providers rotate the token inside a
+ /// refresh that happens on whatever thread an API call was made from — and a value read once a minute
+ /// is current enough for something only a relaunch reads. A failure here costs one browser sign-in on
+ /// the next launch and nothing else, which is not worth interrupting anybody over.
+ ///
+ private async Task RememberSignInAsync(CancellationToken cancellationToken)
+ {
+ if (connection?.RefreshToken is not { } token
+ || Vault is not { } vault
+ || string.Equals(token, rememberedToken, StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ try
+ {
+ await vault.Session.RememberSignInAsync(token, cancellationToken).ConfigureAwait(true);
+ rememberedToken = token;
+ }
+ catch (Exception exception) when (exception is not OutOfMemoryException)
+ {
+ // Left unremembered. The application is signed in for this run either way.
+ }
+ }
+
+ /// Drops the remembered sign-in, so nothing tries to resume it again.
+ private async Task ForgetSignInAsync(CancellationToken cancellationToken)
+ {
+ rememberedToken = null;
+
+ if (Vault is not { } vault)
+ {
+ return;
+ }
+
+ try
+ {
+ await vault.Session.ForgetSignInAsync(cancellationToken).ConfigureAwait(true);
+ }
+ catch (Exception exception) when (exception is not OutOfMemoryException)
+ {
+ // A cache that will not take the deletion is one the next launch will fail to resume from and
+ // then delete itself. Nothing here is worth a message.
+ }
+ }
+
+ ///
+ /// Says something wherever the user is looking.
+ ///
+ ///
+ /// The shell's own message is on the setup and unlock cards, and the status bar shows the vault's — so
+ /// a message about the connection, which is the shell's business but only interesting while somebody
+ /// is using an open vault, has to go to both or it is invisible half the time.
+ ///
+ private void Announce(string message)
+ {
+ StatusMessage = message;
+
+ if (Vault is { } vault)
+ {
+ vault.Status = message;
+ }
+ }
+
///
/// Closes the vault and forgets every key it held. Open shells keep running.
///
@@ -955,10 +1194,170 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
LiveSessionCount = workspace.LiveSessionCount;
+ // A confirmation armed on the preferences screen must not survive onto the unlock screen, where
+ // the same card is offered with a warning it can no longer count.
+ IsConfirmingSignOut = false;
+
State = ShellState.Locked;
StatusMessage = "Locked.";
}
+ // ---- Signing out ----
+
+ /// Whether the sign-out confirmation is showing.
+ ///
+ /// A state rather than a dialog, for the same reason the recovery code is a screen: this is the one
+ /// action in the application that destroys something a user cannot get back from here — an unpushed
+ /// change — and it has to be able to say what is about to go before it goes.
+ ///
+ [ObservableProperty]
+ private bool isConfirmingSignOut;
+
+ ///
+ /// What signing out costs, on this machine, right now.
+ ///
+ ///
+ ///
+ /// The count is the part worth having. Everything else in the vault is on the server and comes back
+ /// with the next sign-in; an operation still in the outbox exists nowhere else in the world, and
+ /// "your changes will be lost" without a number leaves somebody guessing whether it means theirs.
+ ///
+ ///
+ /// A locked vault cannot be counted — the outbox is sealed under the key the vault holds — so it gets
+ /// the honest form of the same warning rather than a zero it has not earned.
+ ///
+ ///
+ internal string SignOutWarning => (IsUnlocked, Vault?.PendingChanges ?? 0) switch
+ {
+ (false, _) =>
+ "Anything this machine changed and has not sent to the server yet will be lost. It cannot be "
+ + "counted from here, because the vault is locked.",
+ (true, 0) =>
+ "Everything this machine has changed has reached the server, so nothing will be lost.",
+ (true, 1) =>
+ "1 change has not reached the server yet and will be lost. Sync first to keep it.",
+ (true, var pending) =>
+ $"{pending} changes have not reached the server yet and will be lost. Sync first to keep them.",
+ };
+
+ /// Asks whether the user means it.
+ [RelayCommand]
+ private void SignOut()
+ {
+ // Taken now so the card can disclose it, on the same reasoning as the lock screen's: signing out
+ // does not close a shell any more than locking does, and a screen that sends somebody back to
+ // "connect to your server" while their upgrade is still running should say so.
+ LiveSessionCount = workspace.LiveSessionCount;
+
+ OnPropertyChanged(nameof(SignOutWarning));
+
+ IsConfirmingSignOut = true;
+ }
+
+ /// Thinks better of it.
+ [RelayCommand]
+ private void CancelSignOut() => IsConfirmingSignOut = false;
+
+ ///
+ /// Signs out: closes the vault, withdraws this machine, and deletes its copy of everything.
+ ///
+ ///
+ ///
+ /// What this does and does not destroy. It empties the local cache — the profile, the wrapped
+ /// bundle, the item mirror, the outbox and the conflict log — and forgets this machine's device key
+ /// here and on the account. The vault itself is on the server and is untouched, which is what makes
+ /// this safe to offer beside a passphrase box: somebody who has forgotten their passphrase can reset
+ /// this machine and sign in again, and the only thing they lose is what this machine had not yet sent.
+ ///
+ ///
+ /// Ordered so that a failure cannot leave a half-signed-out machine. The device is withdrawn
+ /// while there is still a session and a connection to withdraw it through; the vault is closed before
+ /// the cache under it is emptied; and the cache is emptied last, because it is the step that makes
+ /// this machine unenrolled and everything before it is a courtesy that a wiped profile makes moot.
+ ///
+ ///
+ /// It does not end the session at the identity provider — there is no back channel to it from here,
+ /// and pretending otherwise would be the sort of claim this project writes down instead of implying.
+ /// The refresh token this machine held is dropped and never used again; the provider's own session
+ /// outlives it, which is what the preferences screen says out loud.
+ ///
+ ///
+ [RelayCommand]
+ private async Task ConfirmSignOutAsync(CancellationToken cancellationToken)
+ {
+ await RunAsync(
+ "Signing out…",
+ async () =>
+ {
+ IsConfirmingSignOut = false;
+
+ await WithdrawThisMachineAsync(cancellationToken).ConfigureAwait(true);
+
+ // As Lock does, and before the session it reads from goes.
+ knownHosts.Close();
+
+ if (Vault is { } open)
+ {
+ Vault = null;
+ await open.DisposeAsync().ConfigureAwait(true);
+ }
+
+ connection?.Dispose();
+ connection = null;
+ rememberedToken = null;
+
+ await caches.ResetAsync(cancellationToken).ConfigureAwait(true);
+
+ LiveSessionCount = workspace.LiveSessionCount;
+
+ AccountName = null;
+ Passphrase = string.Empty;
+ ConfirmPassphrase = string.Empty;
+ RecoveryCode = null;
+ RecoveryCodeWrittenDown = false;
+ CanUnlockWithDevice = false;
+ CanRegisterDevice = false;
+ CanForgetDevice = false;
+
+ State = ShellState.NeedsServer;
+
+ OnPropertyChanged(nameof(IsOnline));
+ RaiseSyncState();
+
+ StatusMessage = "Signed out. This machine's copy of the vault has been deleted; the vault "
+ + "itself is untouched. Sign in to set this machine up again.";
+ }).ConfigureAwait(true);
+ }
+
+ ///
+ /// Best effort, and swallowed on purpose. Withdrawing the device is the tidy half of signing out — the
+ /// half that stops the account listing a machine whose key is about to be deleted — and a server that
+ /// cannot be reached, or a keystore that declines, must not be able to strand somebody on a screen
+ /// they asked to leave. The half that decides whether this machine can let itself in happens anyway,
+ /// because the profile holding the wrap is emptied a moment later.
+ ///
+ private async Task WithdrawThisMachineAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ if (Vault is { } vault)
+ {
+ await vault.Session
+ .ForgetDeviceAsync(connection?.Account, deviceKeys, cancellationToken)
+ .ConfigureAwait(true);
+
+ await vault.Session.ForgetSignInAsync(cancellationToken).ConfigureAwait(true);
+ return;
+ }
+
+ await deviceKeys.ForgetAsync(cancellationToken).ConfigureAwait(true);
+ }
+ catch (Exception exception) when (exception is not OutOfMemoryException)
+ {
+ // Nothing to report: the wipe below is what signing out actually is.
+ }
+ }
+
///
public async ValueTask DisposeAsync()
{
@@ -1127,6 +1526,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
{
OnPropertyChanged(nameof(IsFullySynced));
OnPropertyChanged(nameof(SyncLabel));
+
+ // The same fact from a third direction: what signing out would cost is the outbox depth, and a
+ // confirmation card left showing a count from before the last pass would be quoting a number that
+ // has since been sent.
+ OnPropertyChanged(nameof(SignOutWarning));
}
///
@@ -1233,8 +1637,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
OnPropertyChanged(nameof(IsNeedingEnrollment));
OnPropertyChanged(nameof(IsShowingRecoveryCode));
OnPropertyChanged(nameof(IsLocked));
+ OnPropertyChanged(nameof(IsAskingForThePassphrase));
OnPropertyChanged(nameof(IsUnlocked));
OnPropertyChanged(nameof(IsTerminalShowing));
+ OnPropertyChanged(nameof(SignOutWarning));
RaiseSyncState();
// Locking leaves the rail wherever it was, and unlocking should not resume on the vault's key list.
@@ -1262,6 +1668,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
partial void OnIsSearchingChanged(bool value) => OnPropertyChanged(nameof(IsTerminalShowing));
+ ///
+ /// The unlock card and the confirmation swap, so arming one has to hide the other — see
+ /// .
+ ///
+ partial void OnIsConfirmingSignOutChanged(bool value) =>
+ OnPropertyChanged(nameof(IsAskingForThePassphrase));
+
partial void OnCanRegisterDeviceChanged(bool value) =>
OnPropertyChanged(nameof(HasNoDeviceKeyOption));
diff --git a/src/DodoSSH.Client.App/ViewModels/VaultViewModel.cs b/src/DodoSSH.Client.App/ViewModels/VaultViewModel.cs
index 978ae24..e373ebe 100644
--- a/src/DodoSSH.Client.App/ViewModels/VaultViewModel.cs
+++ b/src/DodoSSH.Client.App/ViewModels/VaultViewModel.cs
@@ -422,6 +422,24 @@ internal sealed record VaultItemRowViewModel(
internal bool HasBadge => Badge.Length > 0;
}
+///
+/// Gets this machine online, if it can be.
+///
+///
+///
+/// Supplied by the shell, which owns the connection and the remembered sign-in behind it. It is asked
+/// once per synchronisation pass rather than once per vault, and that is what makes coming back from a
+/// closed lid automatic: a laptop that unlocks on a train has no connection and gets one within a minute
+/// of reaching a network, with nothing pressed.
+///
+///
+/// Returns null for every reason a machine may be offline — no remembered sign-in, no network, a token
+/// the provider has stopped accepting — because the vault's answer to all of them is the same: work
+/// locally and queue.
+///
+///
+internal delegate Task ServerReconnectHandler(CancellationToken cancellationToken);
+
///
/// An open vault: the host list, the editor, syncing, and connecting a terminal.
///
@@ -438,6 +456,12 @@ internal sealed record VaultItemRowViewModel(
/// A background pass is deliberately quieter than the button: see .
///
///
+/// Being offline is a state a pass tries to leave, not one it gives up on. Every pass asks the
+/// shell for a connection rather than reading one it was handed at unlock — see
+/// — so a machine that unlocked with no network comes online by
+/// itself once it has one, and a sign-in survives a restart without a browser opening.
+///
+///
/// Everything a connection needs is in the vault. Keys, passwords and host key trust are all synced
/// items, so each is stored once and available on every machine — approving a fingerprint here approves it on
/// every device and survives a restart. A typed password is what is left when a host is bound to nothing, and
@@ -456,7 +480,8 @@ internal sealed partial class VaultViewModel(
VaultSession session,
TerminalWorkspace workspace,
VaultKnownHostStore knownHosts,
- Func connection) : ObservableObject, IAsyncDisposable
+ Func connection,
+ ServerReconnectHandler? reconnect = null) : ObservableObject, IAsyncDisposable
{
///
/// A minute. The pull is a delta keyed on a cursor, so an idle pass is one small request and costs the
@@ -1138,21 +1163,27 @@ internal sealed partial class VaultViewModel(
private static string Endpoint(string host, int port) =>
string.Create(CultureInfo.InvariantCulture, $"{host}:{port}");
- /// Runs a synchronisation pass, if there is a server to talk to.
+ /// Runs a synchronisation pass, if this machine can reach a server.
+ ///
+ /// The offline branch is inside rather than in front of it, because getting
+ /// online is now part of what this button does: resuming a remembered sign-in is a network round trip
+ /// and belongs under the same busy flag as the pass it leads to.
+ ///
[RelayCommand]
private async Task SyncAsync(CancellationToken cancellationToken)
{
- if (connection() is not { } server)
- {
- LastSyncFailed = true;
- Status = "Offline. Changes are queued and will be sent after you sign in.";
- return;
- }
-
await RunAsync(
"Synchronising…",
async () =>
{
+ if (await ResolveServerAsync(cancellationToken).ConfigureAwait(true) is not { } server)
+ {
+ LastSyncFailed = true;
+ Status = "Offline. Changes are queued and will be sent as soon as this machine "
+ + "can reach the server again.";
+ return;
+ }
+
var report = await SyncOnceAsync(server.Sync, cancellationToken).ConfigureAwait(true);
// Null means a background pass held the gate. Saying so beats reporting a sync that this
@@ -1163,6 +1194,18 @@ internal sealed partial class VaultViewModel(
}).ConfigureAwait(true);
}
+ ///
+ /// Finds a server to sync against, getting this machine online if it is not.
+ ///
+ ///
+ /// The handler is asked even when a connection is already held, which looks redundant and is not: the
+ /// shell is the thing that persists the refresh token so a later launch can resume, and identity
+ /// providers rotate that token on every refresh. Asking once per pass is what keeps the remembered
+ /// sign-in current without an event and without this view model knowing what a token is.
+ ///
+ private Task ResolveServerAsync(CancellationToken cancellationToken) =>
+ reconnect is null ? Task.FromResult(connection()) : reconnect(cancellationToken);
+
///
/// Starts syncing in the background until the vault is disposed.
///
@@ -1199,13 +1242,38 @@ internal sealed partial class VaultViewModel(
///
internal async Task AutoSyncAsync(CancellationToken cancellationToken)
{
- if (IsBusy || connection() is not { } server)
+ if (IsBusy)
{
return;
}
+ await SyncOnOpenAsync(cancellationToken).ConfigureAwait(true);
+ }
+
+ ///
+ /// One background synchronisation pass, run whether or not a command is in flight.
+ ///
+ ///
+ ///
+ /// The same quiet pass as without the one thing that made it useless at
+ /// the moment it matters most. The loop is started from inside the unlock command, so the busy flag a
+ /// timed pass yields to is raised by the very command that opened the vault — and the pass on open
+ /// therefore never ran, silently, putting the first synchronisation a full minute after unlock.
+ ///
+ ///
+ /// Yielding is right for every later pass, because by then a busy flag means a person is doing
+ /// something. It is wrong for this one, because the thing it would be yielding to is the unlock.
+ ///
+ ///
+ internal async Task SyncOnOpenAsync(CancellationToken cancellationToken)
+ {
try
{
+ if (await ResolveServerAsync(cancellationToken).ConfigureAwait(true) is not { } server)
+ {
+ return;
+ }
+
var report = await SyncOnceAsync(server.Sync, cancellationToken).ConfigureAwait(true);
if (report is not null && (report.Pulled > 0 || report.Pushed > 0 || report.NeedsAttention))
@@ -1277,7 +1345,14 @@ internal sealed partial class VaultViewModel(
{
// A pass on open, before the first tick. A vault edited on another machine should be current by
// the time the user has finished reading the list, not a minute afterwards.
- await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
+ //
+ // Deliberately not through AutoSyncAsync, and this is not a shortcut. This loop is started from
+ // inside the unlock command, so the busy flag that pass yields to is raised by the very command
+ // that opened the vault — and the pass on open therefore never ran at all. It was a silent
+ // no-op that put the first synchronisation a full minute after unlock, on the launch where
+ // being current matters most. The later passes keep the check: by then, a busy flag means a
+ // user is doing something.
+ await SyncOnOpenAsync(cancellationToken).ConfigureAwait(true);
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(true))
{
diff --git a/src/DodoSSH.Client.App/Views/MainWindow.axaml b/src/DodoSSH.Client.App/Views/MainWindow.axaml
index 413c1ce..7fd644d 100644
--- a/src/DodoSSH.Client.App/Views/MainWindow.axaml
+++ b/src/DodoSSH.Client.App/Views/MainWindow.axaml
@@ -252,7 +252,13 @@
-
+
+
@@ -296,54 +302,22 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
diff --git a/src/DodoSSH.Client.App/Views/MainWindow.axaml.cs b/src/DodoSSH.Client.App/Views/MainWindow.axaml.cs
index 936a77e..afd61a6 100644
--- a/src/DodoSSH.Client.App/Views/MainWindow.axaml.cs
+++ b/src/DodoSSH.Client.App/Views/MainWindow.axaml.cs
@@ -74,6 +74,23 @@ internal sealed partial class MainWindow : Window
_ => this,
};
+ ///
+ /// Where the keyboard belongs once the vault is no longer open.
+ ///
+ ///
+ /// Two ways out of an unlocked vault, and they land on different screens: locking shows the passphrase
+ /// box, and signing out empties this machine and goes back to asking for a server. Both collapse the
+ /// controls the keyboard was on, and Focus() on a collapsed control is a no-op that is not
+ /// replayed when it is revealed — so a fixed target would leave whoever signed out with a window that
+ /// swallows every keystroke until they click something.
+ ///
+ private IInputElement ClosedVaultKeyboardHome => shell?.State switch
+ {
+ ShellState.Locked => UnlockPane.PassphraseBox,
+ ShellState.NeedsServer => ServerUrlBox,
+ _ => this,
+ };
+
///
/// The shortcuts the window owns.
///
@@ -209,7 +226,7 @@ internal sealed partial class MainWindow : Window
// change, and reacting to all of them would move focus during setup and sign-in.
if (wasUnlocked && !unlocked)
{
- ReleaseKeyboardTo(UnlockPassphrase);
+ ReleaseKeyboardTo(ClosedVaultKeyboardHome);
}
wasUnlocked = unlocked;
diff --git a/src/DodoSSH.Client.App/Views/PreferencesScreen.axaml b/src/DodoSSH.Client.App/Views/PreferencesScreen.axaml
index 911f5e7..0bd35c3 100644
--- a/src/DodoSSH.Client.App/Views/PreferencesScreen.axaml
+++ b/src/DodoSSH.Client.App/Views/PreferencesScreen.axaml
@@ -1,6 +1,7 @@
@@ -76,18 +77,49 @@
+ Text="Runs a pass now. One runs on its own when the vault opens, straight after any change, and every minute while it stays open — and a pass that finds this machine offline signs it back in from the session it remembered, so nothing here depends on being pressed." />
+ ToolTip.Tip="Opens your browser. Only needed when there is no remembered session to resume — after signing out, or once your identity provider stops accepting the one this machine held." />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.App/Views/SignOutCard.axaml.cs b/src/DodoSSH.Client.App/Views/SignOutCard.axaml.cs
new file mode 100644
index 0000000..fc83a7c
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/SignOutCard.axaml.cs
@@ -0,0 +1,9 @@
+using Avalonia.Controls;
+
+namespace DodoSSH.Client.App.Views;
+
+/// The sign-out confirmation, shown on the preferences screen and on the unlock screen.
+internal sealed partial class SignOutCard : UserControl
+{
+ public SignOutCard() => InitializeComponent();
+}
diff --git a/src/DodoSSH.Client.App/Views/UnlockCard.axaml b/src/DodoSSH.Client.App/Views/UnlockCard.axaml
new file mode 100644
index 0000000..714cead
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/UnlockCard.axaml
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.App/Views/UnlockCard.axaml.cs b/src/DodoSSH.Client.App/Views/UnlockCard.axaml.cs
new file mode 100644
index 0000000..ea0ada1
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/UnlockCard.axaml.cs
@@ -0,0 +1,19 @@
+using Avalonia.Controls;
+
+namespace DodoSSH.Client.App.Views;
+
+/// The unlock screen's contents.
+internal sealed partial class UnlockCard : UserControl
+{
+ public UnlockCard() => InitializeComponent();
+
+ ///
+ /// Where the keyboard goes when the vault is locked.
+ ///
+ ///
+ /// Exposed the way is, and for the same reason: the window
+ /// owns the focus policy — it has to take the keyboard off the terminal's native child window first —
+ /// and the control it hands it to belongs to whichever screen is showing.
+ ///
+ internal TextBox PassphraseBox => UnlockPassphrase;
+}
diff --git a/src/DodoSSH.Client.Session/ServerConnection.cs b/src/DodoSSH.Client.Session/ServerConnection.cs
index 566eb1c..5ba0c43 100644
--- a/src/DodoSSH.Client.Session/ServerConnection.cs
+++ b/src/DodoSSH.Client.Session/ServerConnection.cs
@@ -41,32 +41,49 @@ internal sealed class RefreshingAccessTokenProvider(
private readonly SemaphoreSlim gate = new(1, 1);
private TokenSet tokens = initial;
+ ///
+ /// The refresh token this provider currently holds, or null when none was granted.
+ ///
+ ///
+ /// Read rather than raised as an event, because the one caller — the shell, persisting it so a later
+ /// launch can resume — has a moment of its own to do that in and no interest in the instant a
+ /// rotation happens. A volatile read of a reference the refresh path replaces wholesale: the value is
+ /// either the old set or the new one, never a half-written one.
+ ///
+ internal string? RefreshToken => Volatile.Read(ref tokens).RefreshToken;
+
public async ValueTask GetAccessTokenAsync(CancellationToken cancellationToken)
{
- if (!tokens.NeedsRefresh(clock))
+ var current = Volatile.Read(ref tokens);
+
+ if (!current.NeedsRefresh(clock))
{
- return tokens.AccessToken;
+ return current.AccessToken;
}
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
- if (!tokens.NeedsRefresh(clock))
+ current = Volatile.Read(ref tokens);
+
+ if (!current.NeedsRefresh(clock))
{
- return tokens.AccessToken;
+ return current.AccessToken;
}
- if (tokens.RefreshToken is null)
+ if (current.RefreshToken is null)
{
throw new InvalidOperationException(
"The access token has expired and no refresh token was granted. Sign in again.");
}
- tokens = await oidc.RefreshAsync(tokens.RefreshToken, cancellationToken)
+ var refreshed = await oidc.RefreshAsync(current.RefreshToken, cancellationToken)
.ConfigureAwait(false);
- return tokens.AccessToken;
+ Volatile.Write(ref tokens, refreshed);
+
+ return refreshed.AccessToken;
}
finally
{
@@ -77,6 +94,26 @@ internal sealed class RefreshingAccessTokenProvider(
public void Dispose() => gate.Dispose();
}
+///
+/// Refuses to open anything, for the flows that must never reach a browser.
+///
+///
+/// uses only the refresh grant, which needs no user agent —
+/// but takes a launcher in its constructor because its other two flows do. This
+/// makes "a resume never opens a browser" a property of the object rather than of the code path, so a
+/// future call that wandered into an interactive flow would fail loudly here instead of surprising
+/// somebody with a sign-in page that opened by itself.
+///
+internal sealed class NoBrowserLauncher : IBrowserLauncher
+{
+ internal static NoBrowserLauncher Instance { get; } = new();
+
+ public Task OpenAsync(Uri url, CancellationToken cancellationToken) =>
+ throw new InvalidOperationException(
+ "This connection was resumed from a remembered sign-in and must not open a browser. "
+ + "Signing in interactively is something the user asks for.");
+}
+
///
/// What a signed-in server offers, as everything above the session layer needs it.
///
@@ -102,6 +139,18 @@ public interface IVaultServer : IDisposable
/// Sync tuning derived from what this server actually accepts.
SyncOptions SyncOptions { get; }
+
+ ///
+ /// The refresh token this connection holds right now, or null when the provider granted none.
+ ///
+ ///
+ /// On the interface because remembering it is what lets a later launch come back online without a
+ /// browser, and the thing doing the remembering — the shell — must not have to know whether it is
+ /// holding a real connection or a test's stand-in. It changes over the life of a connection: a
+ /// provider that rotates hands back a new one on every refresh, so a caller that persists this has
+ /// to re-read it rather than cache it.
+ ///
+ string? RefreshToken { get; }
}
///
@@ -182,6 +231,9 @@ public sealed class ServerConnection : IVaultServer
MaxOperationsPerPush = Math.Clamp(Meta.MaxOperationsPerPush, 1, 500),
};
+ ///
+ public string? RefreshToken => tokens.RefreshToken;
+
///
/// Discovers the server, signs the user in through their browser, and returns the connection.
///
@@ -232,6 +284,77 @@ public sealed class ServerConnection : IVaultServer
}
}
+ ///
+ /// Re-establishes a connection from a remembered refresh token, with no browser and no user present.
+ ///
+ ///
+ ///
+ /// The difference between an application that is signed in and one that merely was. Without this, a
+ /// machine that has been set up is offline from launch until somebody goes and presses a button —
+ /// which means the sync loop, the outbox and a colleague's changes all wait on an action nobody has a
+ /// reason to take.
+ ///
+ ///
+ /// Discovery runs again rather than being cached, because the client is deliberately configured by the
+ /// server: the authority, the client id and the scopes are read from
+ /// /.well-known/dodossh-configuration at every connection, so a deployment that moves its
+ /// identity provider does not leave every client pinned to the old one.
+ ///
+ ///
+ /// It fails rather than falling back when the token has been revoked or has expired, and that is the
+ /// point of passing a launcher that refuses: a resume must never quietly become an interactive
+ /// sign-in, which from a user's side is a browser window that opens on its own. The caller's answer to
+ /// a failure is to stay offline and forget the token.
+ ///
+ ///
+ /// The server this profile is enrolled against.
+ /// The remembered token.
+ /// Time source, for token expiry.
+ /// Cancellation token.
+ public static async Task ResumeAsync(
+ Uri serverUrl,
+ string refreshToken,
+ TimeProvider clock,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(serverUrl);
+ ArgumentException.ThrowIfNullOrWhiteSpace(refreshToken);
+ ArgumentNullException.ThrowIfNull(clock);
+
+ var transport = new HttpClient { BaseAddress = serverUrl };
+
+ try
+ {
+ var discovery = new DodoSshApiClient(transport, UnavailableAccessTokenProvider.Instance);
+
+ var configuration = await discovery.GetConfigurationAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ var meta = await discovery.GetMetaAsync(cancellationToken).ConfigureAwait(false);
+
+ var oidc = new OidcClient(
+ transport, NoBrowserLauncher.Instance, clock, BuildOidcOptions(configuration));
+
+ var tokenSet = await oidc.RefreshAsync(refreshToken, cancellationToken).ConfigureAwait(false);
+
+ var refreshing = new RefreshingAccessTokenProvider(oidc, tokenSet, clock);
+
+ return new ServerConnection(
+ serverUrl,
+ transport,
+ configuration,
+ meta,
+ oidc,
+ refreshing,
+ new DodoSshApiClient(transport, refreshing));
+ }
+ catch
+ {
+ transport.Dispose();
+ throw;
+ }
+ }
+
///
public void Dispose()
{
diff --git a/src/DodoSSH.Client.Session/VaultSession.cs b/src/DodoSSH.Client.Session/VaultSession.cs
index 6798298..3089c14 100644
--- a/src/DodoSSH.Client.Session/VaultSession.cs
+++ b/src/DodoSSH.Client.Session/VaultSession.cs
@@ -77,6 +77,7 @@ public sealed class VaultSession : IAsyncDisposable
Conflicts = new ConflictStore(caches, protector, clock);
Vault = new VaultStore(caches, clock);
Unlock = new UnlockStore(caches, clock);
+ SignIn = new RememberedSignInStore(caches, protector, profile.UserId, clock);
Hosts = new HostRepository(Items, Outbox, keyring);
SshKeys = new SshKeyRepository(Items, Outbox, keyring);
Credentials = new CredentialRepository(Items, Outbox, keyring);
@@ -133,6 +134,51 @@ public sealed class VaultSession : IAsyncDisposable
///
internal UnlockStore Unlock { get; }
+ ///
+ /// Only reachable from an open session, which is the point rather than an accident of where it was
+ /// put: the token is sealed under this session's cache key, so a locked machine cannot read it and
+ /// therefore cannot reach the server at all. See RememberedSignInStore.
+ ///
+ internal RememberedSignInStore SignIn { get; }
+
+ ///
+ /// Remembers the sign-in this machine currently holds, so a later launch can resume it.
+ ///
+ ///
+ /// The refresh token the connection holds now. Providers rotate these, so a caller that
+ /// notices a change has to call this again — the value is not a constant for the life of a sign-in.
+ ///
+ /// Cancellation token.
+ public Task RememberSignInAsync(string refreshToken, CancellationToken cancellationToken)
+ {
+ ObjectDisposedException.ThrowIf(disposed, this);
+
+ return SignIn.SaveAsync(refreshToken, cancellationToken);
+ }
+
+ ///
+ /// Reads the sign-in this machine may resume, or null when there is none to resume.
+ ///
+ ///
+ /// Null covers three situations that are one situation from the caller's side — nothing was ever
+ /// remembered, the record was written under a different identity, or its tag no longer verifies — and
+ /// the answer to all three is the same: sign in through the browser.
+ ///
+ public Task ReadRememberedSignInAsync(CancellationToken cancellationToken)
+ {
+ ObjectDisposedException.ThrowIf(disposed, this);
+
+ return SignIn.ReadAsync(cancellationToken);
+ }
+
+ /// Forgets the remembered sign-in.
+ public Task ForgetSignInAsync(CancellationToken cancellationToken)
+ {
+ ObjectDisposedException.ThrowIf(disposed, this);
+
+ return SignIn.ForgetAsync(cancellationToken);
+ }
+
/// Runs one synchronisation pass over the active vault.
/// The transport. Supplied per call because a session outlives any one connection.
/// Cancellation token.
diff --git a/src/DodoSSH.Client.Storage/CacheRows.cs b/src/DodoSSH.Client.Storage/CacheRows.cs
index 8b14066..997635d 100644
--- a/src/DodoSSH.Client.Storage/CacheRows.cs
+++ b/src/DodoSSH.Client.Storage/CacheRows.cs
@@ -78,6 +78,37 @@ internal sealed class UnlockMaterialRow
public DateTimeOffset UpdatedAtUtc { get; set; }
}
+///
+/// The sign-in this machine may resume without opening a browser.
+///
+///
+///
+/// A single row, like and for the same reason: one cache holds one
+/// account.
+///
+///
+/// The token is sealed under the LocalCacheKey, which is the whole point of storing it here. A
+/// refresh token is a long-lived credential for the account — not for the vault, which nothing but the
+/// passphrase opens — so a copy of this file lifted off a stolen laptop must not be one. Sealing it under
+/// a key that exists only while the vault is unlocked means the sign-in can only be resumed by somebody
+/// who has already opened the vault, which is exactly the moment the application wants it: unlock, then
+/// come back online by itself. It also means a locked machine cannot reach the server at all, which is a
+/// consequence worth stating rather than a limitation to work around.
+///
+///
+internal sealed class RememberedSignInRow
+{
+ /// The only legal primary key.
+ internal const int SingletonId = 1;
+
+ public int Id { get; set; } = SingletonId;
+
+ /// The refresh token, sealed under the LocalCacheKey. Ciphertext.
+ public byte[] SealedRefreshToken { get; set; } = [];
+
+ public DateTimeOffset UpdatedAtUtc { get; set; }
+}
+
/// A vault the user can reach, with the grant that opens it.
///
/// Cached so the vault list and the key needed to decrypt it are both available offline. The name is
diff --git a/src/DodoSSH.Client.Storage/ClientCacheContext.cs b/src/DodoSSH.Client.Storage/ClientCacheContext.cs
index 7069202..3226b74 100644
--- a/src/DodoSSH.Client.Storage/ClientCacheContext.cs
+++ b/src/DodoSSH.Client.Storage/ClientCacheContext.cs
@@ -73,6 +73,7 @@ public sealed class ClientCacheContext(DbContextOptions opti
ArgumentNullException.ThrowIfNull(modelBuilder);
ConfigureUnlockMaterial(modelBuilder);
+ ConfigureRememberedSignIn(modelBuilder);
ConfigureVaults(modelBuilder);
ConfigureItems(modelBuilder);
ConfigureOutbox(modelBuilder);
@@ -102,6 +103,26 @@ public sealed class ClientCacheContext(DbContextOptions opti
entity.Property(row => row.KdfSalt).IsRequired();
});
+ ///
+ /// A table of its own rather than two more columns on unlock_material, because the two rows have
+ /// opposite lifetimes: the unlock material is what makes this machine work offline and must survive
+ /// everything short of a reset, while a remembered sign-in is dropped the moment the server stops
+ /// accepting it. Deleting one must never be able to take the other with it.
+ ///
+ private static void ConfigureRememberedSignIn(ModelBuilder modelBuilder) =>
+ modelBuilder.Entity(entity =>
+ {
+ entity.ToTable(
+ "remembered_sign_in",
+ table => table.HasCheckConstraint(
+ "ck_remembered_sign_in_singleton",
+ $"id = {RememberedSignInRow.SingletonId}"));
+
+ entity.HasKey(row => row.Id);
+ entity.Property(row => row.Id).ValueGeneratedNever();
+ entity.Property(row => row.SealedRefreshToken).IsRequired();
+ });
+
private static void ConfigureVaults(ModelBuilder modelBuilder) =>
modelBuilder.Entity(entity =>
{
diff --git a/src/DodoSSH.Client.Storage/ClientCacheFactory.cs b/src/DodoSSH.Client.Storage/ClientCacheFactory.cs
index 7d10984..1213052 100644
--- a/src/DodoSSH.Client.Storage/ClientCacheFactory.cs
+++ b/src/DodoSSH.Client.Storage/ClientCacheFactory.cs
@@ -123,6 +123,59 @@ public sealed class ClientCacheFactory : IDbContextFactory,
await context.Database.MigrateAsync(cancellationToken).ConfigureAwait(false);
}
+ ///
+ /// Empties the cache: every row of every table, and the pages they were written on.
+ ///
+ ///
+ ///
+ /// What signing out means on disk. The profile, the wrapped bundle, the item mirror, the outbox and
+ /// the conflict log all go; the schema stays, so the application is usable again immediately and does
+ /// not have to be restarted to be set up afresh.
+ ///
+ ///
+ /// Emptied rather than deleted, and then vacuumed. Deleting the file is the obvious move and is
+ /// worse here: the database is in WAL mode, so it is three files rather than one — a routine that
+ /// removes cache.db and leaves -wal behind loses to a checkpoint that puts some of it
+ /// back — and on Windows the pooled connections hold the file open, so the delete fails outright while
+ /// the application is running. The VACUUM is the half that makes this a wipe rather than a
+ /// hide: SQLite marks deleted pages free without overwriting them, so ciphertext and sealed records
+ /// would otherwise stay legible in the file until something happened to reuse the page.
+ ///
+ ///
+ /// The migrations history is deliberately left alone. It describes the shape of the tables, not the
+ /// user, and clearing it would make the next launch try to apply every migration to a schema that
+ /// already has them.
+ ///
+ ///
+ public async Task ResetAsync(CancellationToken cancellationToken)
+ {
+ var context = CreateDbContext();
+ await using var scope = context.ConfigureAwait(false);
+
+ // Taken from the model rather than listed here, so a table added later is emptied by a sign-out
+ // without anyone having to remember this method exists.
+ var tables = context.Model.GetEntityTypes()
+ .Select(entityType => entityType.GetTableName())
+ .OfType()
+ .Distinct(StringComparer.Ordinal);
+
+ foreach (var table in tables)
+ {
+ // A table name cannot be a parameter, so it is quoted rather than bound. The value comes from
+ // this assembly's own model metadata and never from input; the doubling is what keeps that
+ // true of a name somebody eventually writes with a quote in it.
+ var sql = string.Concat("DELETE FROM \"", table.Replace("\"", "\"\"", StringComparison.Ordinal), "\"");
+
+ // EF1002 and CA2100 both describe interpolating a value into SQL, which is what the two lines
+ // above are; the value is the one thing here that cannot come from a user.
+#pragma warning disable EF1002, CA2100
+ await context.Database.ExecuteSqlRawAsync(sql, cancellationToken).ConfigureAwait(false);
+#pragma warning restore EF1002, CA2100
+ }
+
+ await context.Database.ExecuteSqlRawAsync("VACUUM", cancellationToken).ConfigureAwait(false);
+ }
+
///
public void Dispose()
{
diff --git a/src/DodoSSH.Client.Storage/Migrations/20260731082424_AddRememberedSignIn.Designer.cs b/src/DodoSSH.Client.Storage/Migrations/20260731082424_AddRememberedSignIn.Designer.cs
new file mode 100644
index 0000000..faf40ba
--- /dev/null
+++ b/src/DodoSSH.Client.Storage/Migrations/20260731082424_AddRememberedSignIn.Designer.cs
@@ -0,0 +1,440 @@
+//
+using System;
+using DodoSSH.Client.Storage;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace DodoSSH.Client.Storage.Migrations
+{
+ [DbContext(typeof(ClientCacheContext))]
+ [Migration("20260731082424_AddRememberedSignIn")]
+ partial class AddRememberedSignIn
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
+
+ modelBuilder.Entity("DodoSSH.Client.Storage.CachedItemRow", b =>
+ {
+ b.Property("VaultId")
+ .HasColumnType("TEXT")
+ .HasColumnName("vault_id");
+
+ b.Property("EntityType")
+ .HasColumnType("INTEGER")
+ .HasColumnName("entity_type");
+
+ b.Property("EntityId")
+ .HasColumnType("TEXT")
+ .HasColumnName("entity_id");
+
+ b.Property("AadVersion")
+ .HasColumnType("INTEGER")
+ .HasColumnName("aad_version");
+
+ b.Property("ChangeSequence")
+ .HasColumnType("INTEGER")
+ .HasColumnName("change_sequence");
+
+ b.Property("DataKeyId")
+ .HasColumnType("TEXT")
+ .HasColumnName("data_key_id");
+
+ b.Property("IsDeleted")
+ .HasColumnType("INTEGER")
+ .HasColumnName("is_deleted");
+
+ b.Property("KeyGeneration")
+ .HasColumnType("INTEGER")
+ .HasColumnName("key_generation");
+
+ b.Property("Payload")
+ .HasColumnType("BLOB")
+ .HasColumnName("payload");
+
+ b.Property("ProtectedFields")
+ .HasColumnType("BLOB")
+ .HasColumnName("protected_fields");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("INTEGER")
+ .HasColumnName("updated_at_utc");
+
+ b.Property("Version")
+ .HasColumnType("INTEGER")
+ .HasColumnName("version");
+
+ b.Property("WrappedDataKey")
+ .HasColumnType("BLOB")
+ .HasColumnName("wrapped_data_key");
+
+ b.HasKey("VaultId", "EntityType", "EntityId")
+ .HasName("pk_item");
+
+ b.HasIndex("VaultId", "ChangeSequence")
+ .HasDatabaseName("ix_item_vault_id_change_sequence");
+
+ b.HasIndex("VaultId", "EntityType")
+ .HasDatabaseName("ix_item_vault_id_entity_type");
+
+ b.ToTable("item", (string)null);
+ });
+
+ modelBuilder.Entity("DodoSSH.Client.Storage.CachedVaultRow", b =>
+ {
+ b.Property("VaultId")
+ .HasColumnType("TEXT")
+ .HasColumnName("vault_id");
+
+ b.Property("IsPersonal")
+ .HasColumnType("INTEGER")
+ .HasColumnName("is_personal");
+
+ b.Property("KeyGeneration")
+ .HasColumnType("INTEGER")
+ .HasColumnName("key_generation");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("name");
+
+ b.Property("Permissions")
+ .HasColumnType("INTEGER")
+ .HasColumnName("permissions");
+
+ b.Property("RekeyRequired")
+ .HasColumnType("INTEGER")
+ .HasColumnName("rekey_required");
+
+ b.Property("TeamId")
+ .HasColumnType("TEXT")
+ .HasColumnName("team_id");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("INTEGER")
+ .HasColumnName("updated_at_utc");
+
+ b.Property("WrappedVaultKey")
+ .HasColumnType("BLOB")
+ .HasColumnName("wrapped_vault_key");
+
+ b.HasKey("VaultId")
+ .HasName("pk_vault");
+
+ b.ToTable("vault", (string)null);
+ });
+
+ modelBuilder.Entity("DodoSSH.Client.Storage.ConflictRow", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT")
+ .HasColumnName("id");
+
+ b.Property("Acknowledged")
+ .HasColumnType("INTEGER")
+ .HasColumnName("acknowledged");
+
+ b.Property("Detail")
+ .IsRequired()
+ .HasColumnType("BLOB")
+ .HasColumnName("detail");
+
+ b.Property("DetectedAtUtc")
+ .HasColumnType("INTEGER")
+ .HasColumnName("detected_at_utc");
+
+ b.Property("EntityId")
+ .HasColumnType("TEXT")
+ .HasColumnName("entity_id");
+
+ b.Property("EntityType")
+ .HasColumnType("INTEGER")
+ .HasColumnName("entity_type");
+
+ b.Property("Kind")
+ .HasColumnType("INTEGER")
+ .HasColumnName("kind");
+
+ b.Property("VaultId")
+ .HasColumnType("TEXT")
+ .HasColumnName("vault_id");
+
+ b.HasKey("Id")
+ .HasName("pk_conflict");
+
+ b.HasIndex("VaultId", "Acknowledged")
+ .HasDatabaseName("ix_conflict_vault_id_acknowledged");
+
+ b.HasIndex("VaultId", "EntityType", "EntityId")
+ .HasDatabaseName("ix_conflict_vault_id_entity_type_entity_id");
+
+ b.ToTable("conflict", (string)null);
+ });
+
+ modelBuilder.Entity("DodoSSH.Client.Storage.OutboxRow", b =>
+ {
+ b.Property("Sequence")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasColumnName("sequence");
+
+ b.Property("AadVersion")
+ .HasColumnType("INTEGER")
+ .HasColumnName("aad_version");
+
+ b.Property("AncestorAadVersion")
+ .HasColumnType("INTEGER")
+ .HasColumnName("ancestor_aad_version");
+
+ b.Property("AncestorDataKeyId")
+ .HasColumnType("TEXT")
+ .HasColumnName("ancestor_data_key_id");
+
+ b.Property("AncestorKeyGeneration")
+ .HasColumnType("INTEGER")
+ .HasColumnName("ancestor_key_generation");
+
+ b.Property("AncestorPayload")
+ .HasColumnType("BLOB")
+ .HasColumnName("ancestor_payload");
+
+ b.Property("AncestorProtectedFields")
+ .HasColumnType("BLOB")
+ .HasColumnName("ancestor_protected_fields");
+
+ b.Property("AncestorVersion")
+ .HasColumnType("INTEGER")
+ .HasColumnName("ancestor_version");
+
+ b.Property("AncestorWrappedDataKey")
+ .HasColumnType("BLOB")
+ .HasColumnName("ancestor_wrapped_data_key");
+
+ b.Property("Attempts")
+ .HasColumnType("INTEGER")
+ .HasColumnName("attempts");
+
+ b.Property("DataKeyId")
+ .HasColumnType("TEXT")
+ .HasColumnName("data_key_id");
+
+ b.Property("EntityId")
+ .HasColumnType("TEXT")
+ .HasColumnName("entity_id");
+
+ b.Property("EntityType")
+ .HasColumnType("INTEGER")
+ .HasColumnName("entity_type");
+
+ b.Property("ExpectedVersion")
+ .HasColumnType("INTEGER")
+ .HasColumnName("expected_version");
+
+ b.Property("IsParked")
+ .HasColumnType("INTEGER")
+ .HasColumnName("is_parked");
+
+ b.Property("KeyGeneration")
+ .HasColumnType("INTEGER")
+ .HasColumnName("key_generation");
+
+ b.Property("LastError")
+ .HasColumnType("TEXT")
+ .HasColumnName("last_error");
+
+ b.Property("Operation")
+ .HasColumnType("INTEGER")
+ .HasColumnName("operation");
+
+ b.Property("OperationId")
+ .HasColumnType("TEXT")
+ .HasColumnName("operation_id");
+
+ b.Property("Payload")
+ .HasColumnType("BLOB")
+ .HasColumnName("payload");
+
+ b.Property("ProtectedFields")
+ .HasColumnType("BLOB")
+ .HasColumnName("protected_fields");
+
+ b.Property("QueuedAtUtc")
+ .HasColumnType("INTEGER")
+ .HasColumnName("queued_at_utc");
+
+ b.Property("VaultId")
+ .HasColumnType("TEXT")
+ .HasColumnName("vault_id");
+
+ b.Property("WrappedDataKey")
+ .HasColumnType("BLOB")
+ .HasColumnName("wrapped_data_key");
+
+ b.HasKey("Sequence")
+ .HasName("pk_outbox");
+
+ b.HasIndex("OperationId")
+ .IsUnique()
+ .HasDatabaseName("ix_outbox_operation_id");
+
+ b.HasIndex("VaultId", "EntityType", "EntityId")
+ .IsUnique()
+ .HasDatabaseName("ix_outbox_vault_id_entity_type_entity_id");
+
+ b.HasIndex("VaultId", "IsParked", "Sequence")
+ .HasDatabaseName("ix_outbox_vault_id_is_parked_sequence");
+
+ b.ToTable("outbox", (string)null);
+ });
+
+ modelBuilder.Entity("DodoSSH.Client.Storage.RememberedSignInRow", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("INTEGER")
+ .HasColumnName("id");
+
+ b.Property("SealedRefreshToken")
+ .IsRequired()
+ .HasColumnType("BLOB")
+ .HasColumnName("sealed_refresh_token");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("INTEGER")
+ .HasColumnName("updated_at_utc");
+
+ b.HasKey("Id")
+ .HasName("pk_remembered_sign_in");
+
+ b.ToTable("remembered_sign_in", null, t =>
+ {
+ t.HasCheckConstraint("ck_remembered_sign_in_singleton", "id = 1");
+ });
+ });
+
+ modelBuilder.Entity("DodoSSH.Client.Storage.SyncStateRow", b =>
+ {
+ b.Property("VaultId")
+ .HasColumnType("TEXT")
+ .HasColumnName("vault_id");
+
+ b.Property("Cursor")
+ .HasColumnType("TEXT")
+ .HasColumnName("cursor");
+
+ b.Property("KeyGeneration")
+ .HasColumnType("INTEGER")
+ .HasColumnName("key_generation");
+
+ b.Property("LastPulledAtUtc")
+ .HasColumnType("INTEGER")
+ .HasColumnName("last_pulled_at_utc");
+
+ b.Property("LastPushedAtUtc")
+ .HasColumnType("INTEGER")
+ .HasColumnName("last_pushed_at_utc");
+
+ b.Property("ServerTimeSkewMs")
+ .HasColumnType("INTEGER")
+ .HasColumnName("server_time_skew_ms");
+
+ b.HasKey("VaultId")
+ .HasName("pk_sync_state");
+
+ b.ToTable("sync_state", (string)null);
+ });
+
+ modelBuilder.Entity("DodoSSH.Client.Storage.UnlockMaterialRow", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("INTEGER")
+ .HasColumnName("id");
+
+ b.Property("DeviceId")
+ .HasColumnType("TEXT")
+ .HasColumnName("device_id");
+
+ b.Property("DeviceWrappedPrivateKey")
+ .HasColumnType("BLOB")
+ .HasColumnName("device_wrapped_private_key");
+
+ b.Property("DisplayName")
+ .HasColumnType("TEXT")
+ .HasColumnName("display_name");
+
+ b.Property("Email")
+ .HasColumnType("TEXT")
+ .HasColumnName("email");
+
+ b.Property("Issuer")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("issuer");
+
+ b.Property("KdfAlgorithm")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("kdf_algorithm");
+
+ b.Property("KdfMemoryKibibytes")
+ .HasColumnType("INTEGER")
+ .HasColumnName("kdf_memory_kibibytes");
+
+ b.Property("KdfParallelism")
+ .HasColumnType("INTEGER")
+ .HasColumnName("kdf_parallelism");
+
+ b.Property("KdfPasses")
+ .HasColumnType("INTEGER")
+ .HasColumnName("kdf_passes");
+
+ b.Property("KdfSalt")
+ .IsRequired()
+ .HasColumnType("BLOB")
+ .HasColumnName("kdf_salt");
+
+ b.Property("KeyGeneration")
+ .HasColumnType("INTEGER")
+ .HasColumnName("key_generation");
+
+ b.Property("ServerUrl")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("server_url");
+
+ b.Property("Subject")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("subject");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("INTEGER")
+ .HasColumnName("updated_at_utc");
+
+ b.Property("UserId")
+ .HasColumnType("TEXT")
+ .HasColumnName("user_id");
+
+ b.Property("WrappedPrivateKey")
+ .IsRequired()
+ .HasColumnType("BLOB")
+ .HasColumnName("wrapped_private_key");
+
+ b.HasKey("Id")
+ .HasName("pk_unlock_material");
+
+ b.ToTable("unlock_material", null, t =>
+ {
+ t.HasCheckConstraint("ck_unlock_material_singleton", "id = 1");
+ });
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/DodoSSH.Client.Storage/Migrations/20260731082424_AddRememberedSignIn.cs b/src/DodoSSH.Client.Storage/Migrations/20260731082424_AddRememberedSignIn.cs
new file mode 100644
index 0000000..76f91c1
--- /dev/null
+++ b/src/DodoSSH.Client.Storage/Migrations/20260731082424_AddRememberedSignIn.cs
@@ -0,0 +1,35 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace DodoSSH.Client.Storage.Migrations
+{
+ ///
+ public partial class AddRememberedSignIn : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "remembered_sign_in",
+ columns: table => new
+ {
+ id = table.Column(type: "INTEGER", nullable: false),
+ sealed_refresh_token = table.Column(type: "BLOB", nullable: false),
+ updated_at_utc = table.Column(type: "INTEGER", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_remembered_sign_in", x => x.id);
+ table.CheckConstraint("ck_remembered_sign_in_singleton", "id = 1");
+ });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "remembered_sign_in");
+ }
+ }
+}
diff --git a/src/DodoSSH.Client.Storage/Migrations/ClientCacheContextModelSnapshot.cs b/src/DodoSSH.Client.Storage/Migrations/ClientCacheContextModelSnapshot.cs
index cb9478b..8444a02 100644
--- a/src/DodoSSH.Client.Storage/Migrations/ClientCacheContextModelSnapshot.cs
+++ b/src/DodoSSH.Client.Storage/Migrations/ClientCacheContextModelSnapshot.cs
@@ -291,6 +291,30 @@ namespace DodoSSH.Client.Storage.Migrations
b.ToTable("outbox", (string)null);
});
+ modelBuilder.Entity("DodoSSH.Client.Storage.RememberedSignInRow", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("INTEGER")
+ .HasColumnName("id");
+
+ b.Property("SealedRefreshToken")
+ .IsRequired()
+ .HasColumnType("BLOB")
+ .HasColumnName("sealed_refresh_token");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("INTEGER")
+ .HasColumnName("updated_at_utc");
+
+ b.HasKey("Id")
+ .HasName("pk_remembered_sign_in");
+
+ b.ToTable("remembered_sign_in", null, t =>
+ {
+ t.HasCheckConstraint("ck_remembered_sign_in_singleton", "id = 1");
+ });
+ });
+
modelBuilder.Entity("DodoSSH.Client.Storage.SyncStateRow", b =>
{
b.Property("VaultId")
diff --git a/src/DodoSSH.Client.Storage/RememberedSignInStore.cs b/src/DodoSSH.Client.Storage/RememberedSignInStore.cs
new file mode 100644
index 0000000..31aa031
--- /dev/null
+++ b/src/DodoSSH.Client.Storage/RememberedSignInStore.cs
@@ -0,0 +1,122 @@
+using System.Security.Cryptography;
+using System.Text;
+using DodoSSH.Crypto;
+using Microsoft.EntityFrameworkCore;
+
+namespace DodoSSH.Client.Storage;
+
+///
+/// The sign-in this machine may resume without opening a browser.
+///
+///
+///
+/// One refresh token, sealed under the LocalCacheKey and bound to the user it belongs to. Everything about
+/// why it is sealed rather than stored — and what a locked machine therefore cannot do — is on
+/// .
+///
+///
+/// The server it belongs to is deliberately not recorded here: unlock_material already holds it, and
+/// two copies of one fact is two facts that can disagree. A cache holds one account and one server.
+///
+///
+public sealed class RememberedSignInStore(
+ IDbContextFactory contexts,
+ LocalCacheProtector protector,
+ Guid userId,
+ TimeProvider clock)
+{
+ /// Remembers a refresh token, replacing whatever was there.
+ ///
+ /// Called again whenever the provider rotates the token. Keeping the one this client first received
+ /// would leave a rotating provider refusing the next launch, which is the failure that reads as "the
+ /// application randomly signs me out".
+ ///
+ public async Task SaveAsync(string refreshToken, CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(refreshToken);
+
+ var context = contexts.CreateDbContext();
+ await using var scope = context.ConfigureAwait(false);
+
+ var row = await context.Set()
+ .SingleOrDefaultAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ if (row is null)
+ {
+ row = new RememberedSignInRow();
+ context.Add(row);
+ }
+
+ var bytes = Encoding.UTF8.GetBytes(refreshToken);
+
+ try
+ {
+ row.SealedRefreshToken = protector.Protect(
+ CryptoSpec.AadResourceType.User, userId, bytes);
+ }
+ finally
+ {
+ // The managed copy this method made, not the string it was handed — see the remark on
+ // ReadAsync for what a .NET string does and does not allow here.
+ CryptographicOperations.ZeroMemory(bytes);
+ }
+
+ row.UpdatedAtUtc = clock.GetUtcNow();
+
+ await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Reads the remembered token, or null when there is none this key can open.
+ ///
+ ///
+ ///
+ /// Null rather than an exception for a record that will not open, on the same reasoning as
+ /// : a cache written under a different identity is an
+ /// ordinary situation and the answer is to sign in again, not to fail.
+ ///
+ ///
+ /// It comes back as a , which cannot be wiped. That is the same bargain the private
+ /// key and password editors already make — every HTTP client on the way to the token endpoint wants a
+ /// string — and pretending otherwise with a SecureString would buy nothing this process's memory
+ /// does not already give away.
+ ///
+ ///
+ public async Task ReadAsync(CancellationToken cancellationToken)
+ {
+ var context = contexts.CreateDbContext();
+ await using var scope = context.ConfigureAwait(false);
+
+ var row = await context.Set()
+ .AsNoTracking()
+ .SingleOrDefaultAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ if (row is null)
+ {
+ return null;
+ }
+
+ var opened = protector.TryUnprotect(
+ CryptoSpec.AadResourceType.User, userId, row.SealedRefreshToken);
+
+ return opened is null ? null : Encoding.UTF8.GetString(opened);
+ }
+
+ /// Forgets the remembered sign-in, so the next launch has to use a browser.
+ ///
+ /// Used when the provider refuses the token — a revoked session, a rotation this machine missed — as
+ /// well as when the user signs out. Keeping a token that has already been refused would mean retrying
+ /// it once a minute for the life of the profile.
+ ///
+ public async Task ForgetAsync(CancellationToken cancellationToken)
+ {
+ var context = contexts.CreateDbContext();
+ await using var scope = context.ConfigureAwait(false);
+
+ await context.Set()
+ .ExecuteDeleteAsync(cancellationToken)
+ .ConfigureAwait(false);
+ }
+}
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/LayoutHarness.cs b/tests/DodoSSH.Client.App.Layout.Tests/LayoutHarness.cs
index b50c2cc..b4fc3bb 100644
--- a/tests/DodoSSH.Client.App.Layout.Tests/LayoutHarness.cs
+++ b/tests/DodoSSH.Client.App.Layout.Tests/LayoutHarness.cs
@@ -55,6 +55,20 @@ internal static class LayoutHarness
///
internal const double StatusBarHeight = 24;
+ ///
+ /// What a setup card leaves its contents: its maximum width, less the padding on both sides.
+ ///
+ ///
+ /// From Border.card in App.axaml — MaxWidth 520 and Padding 24 — because the
+ /// cards themselves live inside MainWindow.axaml, which cannot be laid out here at all. Measuring
+ /// a card's contents at the size the card gives them is the closest this harness can get to the unlock
+ /// screen, and it is the half that has something to blow: the frame is fixed and the contents are not.
+ ///
+ internal const double CardContentWidth = 520 - (2 * 24);
+
+ ///
+ internal static double CardContentHeight => ScreenHeight - (2 * 24);
+
/// The height a screen actually gets at the window's minimum.
internal static double ScreenHeight => MinimumHeight - TitleBarHeight - StatusBarHeight;
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/LayoutHarnessTests.cs b/tests/DodoSSH.Client.App.Layout.Tests/LayoutHarnessTests.cs
index e2a1321..a3ca8df 100644
--- a/tests/DodoSSH.Client.App.Layout.Tests/LayoutHarnessTests.cs
+++ b/tests/DodoSSH.Client.App.Layout.Tests/LayoutHarnessTests.cs
@@ -1,5 +1,6 @@
using System.Runtime.InteropServices;
using Avalonia.Controls;
+using Avalonia.Input;
using DodoSSH.Client.App.Views;
namespace DodoSSH.Client.App.Layout.Tests;
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs b/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs
index 38c3eac..491e54e 100644
--- a/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs
+++ b/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs
@@ -1,4 +1,5 @@
using Avalonia.Controls;
+using Avalonia.Input;
using Avalonia.VisualTree;
using DodoSSH.Client.App.ViewModels;
using DodoSSH.Client.App.Views;
@@ -54,6 +55,13 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
private VaultSession session = null!;
private VaultViewModel vault = null!;
+ ///
+ /// Constructed and never started: the sign-out card binds to the shell rather than to a vault, and what
+ /// it shows comes from properties a fresh one already answers. Starting it would migrate a cache and
+ /// read a profile, neither of which any rectangle here depends on.
+ ///
+ private MainWindowViewModel shell = null!;
+
private static CancellationToken Token => TestContext.Current.CancellationToken;
///
@@ -82,12 +90,23 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
// every sync pass out of a suite that is only measuring rectangles.
vault = new VaultViewModel(session, workspace, knownHosts, static () => null);
+ shell = new MainWindowViewModel(
+ new ClientPaths(Path.Combine(Path.GetTempPath(), $"dodossh-layout-{Guid.CreateVersion7():N}")),
+ caches,
+ workspace,
+ knownHosts,
+ new UnavailableDeviceKeyStore(),
+ static (_, _) => throw new InvalidOperationException("A layout test has no network."),
+ TimeProvider.System,
+ CheapProfile);
+
await SeedAsync();
}
///
public async ValueTask DisposeAsync()
{
+ await shell.DisposeAsync();
await vault.DisposeAsync();
knownHosts.Close();
await workspace.DisposeAsync();
@@ -363,6 +382,111 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
Token);
}
+ // ---- The unlock screen ----
+
+ ///
+ ///
+ /// The card a locked application is entirely made of, in its two shapes: an ordinary launch, and one
+ /// where shells were left running and the disclosure about them appears. It was extracted from
+ /// MainWindow.axaml to be measurable at all — that window cannot be shown here, so anything
+ /// inside it is unmeasured by construction — and it is the card with the least room to spare.
+ ///
+ ///
+ /// The status line is set to something long on purpose. It is bound to whatever the last thing that
+ /// happened said, and the longest of those is a sentence about an expired sign-in, which is exactly the
+ /// message this screen is most likely to be carrying on the launch where the extra rows also appear.
+ ///
+ ///
+ [Theory]
+ [InlineData(0)]
+ [InlineData(2)]
+ public async Task TheUnlockCardFitsTheCardItIsShownIn(int liveSessions)
+ {
+ shell.LiveSessionCount = liveSessions;
+ shell.CanUnlockWithDevice = true;
+ shell.StatusMessage = "Your sign-in has expired, so this machine is offline: the token endpoint "
+ + "returned 400: Invalid refresh token. Sign in again from Preferences to start syncing.";
+
+ await MeasureCardAsync(new UnlockCard());
+ }
+
+ [Fact]
+ public async Task TheUnlockBoxTakesEnterAsUnlock()
+ {
+ // Enter is how everybody finishes typing a password, and this screen had no answer to it until the
+ // gesture below existed: the passphrase box is where locking puts the keyboard, so the one thing a
+ // user does without thinking did nothing at all until they found the button.
+ //
+ // The gesture is what can be asserted; that pressing it unlocks is ShellFlowTests' business,
+ // against the command this binds to.
+ await LayoutHarness.OnTheUiThreadAsync(
+ () =>
+ {
+ var card = new UnlockCard { DataContext = shell };
+ var window = LayoutHarness.HostAtMinimumSize(
+ card, LayoutHarness.CardContentWidth, LayoutHarness.CardContentHeight);
+
+ try
+ {
+ var binding = card.PassphraseBox.KeyBindings.ShouldHaveSingleItem();
+
+ binding.Gesture.ShouldBe(new KeyGesture(Key.Enter));
+ binding.Command.ShouldBeSameAs(shell.UnlockCommand);
+ }
+ finally
+ {
+ window.Close();
+ }
+ },
+ Token);
+ }
+
+ // ---- The sign-out confirmation ----
+
+ ///
+ ///
+ /// The one new card that has to share a screen with an unlock prompt, and the only one whose height
+ /// depends on what it is saying: the warning is a sentence about the outbox, and the disclosure about
+ /// shells left running appears only when there are some. Both are wrapped paragraphs, which is the
+ /// shape that grows.
+ ///
+ ///
+ /// Measured in the space a card gives its contents rather than inside MainWindow, which cannot
+ /// be laid out here — see LayoutHarnessTests.WhyTheWindowItselfIsNeverShown. What that leaves
+ /// unchecked is the card's own frame, which is a fixed border and a constant padding.
+ ///
+ ///
+ [Fact]
+ public async Task TheSignOutCardFitsTheCardItIsShownIn()
+ {
+ // Its tallest shape: a shell left running adds a disclosure box that an ordinary sign-out does not
+ // have, and a locked vault carries the longer of the two warnings.
+ shell.LiveSessionCount = 1;
+
+ await MeasureCardAsync(new SignOutCard());
+ }
+
+ /// Lays a setup-screen card out in the space Border.card gives its contents.
+ private Task MeasureCardAsync(Control card) =>
+ LayoutHarness.OnTheUiThreadAsync(
+ () =>
+ {
+ card.DataContext = shell;
+
+ var window = LayoutHarness.HostAtMinimumSize(
+ card, LayoutHarness.CardContentWidth, LayoutHarness.CardContentHeight);
+
+ try
+ {
+ LayoutHarness.Unreachable(window).ShouldBeEmpty();
+ }
+ finally
+ {
+ window.Close();
+ }
+ },
+ Token);
+
// ---- Helpers ----
/// Lays the sidebar out at the width the hosts screen gives it.
diff --git a/tests/DodoSSH.Client.App.Tests/FakeVaultServer.cs b/tests/DodoSSH.Client.App.Tests/FakeVaultServer.cs
index 3c73088..7bafc45 100644
--- a/tests/DodoSSH.Client.App.Tests/FakeVaultServer.cs
+++ b/tests/DodoSSH.Client.App.Tests/FakeVaultServer.cs
@@ -77,6 +77,16 @@ internal sealed class FakeVaultServer : IVaultServer, IAccountApi, ISyncApi, IKe
///
public SyncOptions SyncOptions => SyncOptions.Default;
+ ///
+ /// The refresh token this "connection" holds.
+ ///
+ ///
+ /// Settable, because rotation is the half of remembering a sign-in that is easy to get wrong: a shell
+ /// that persisted the token it first saw would leave a rotating provider refusing the next launch. A
+ /// test changes this and asserts the new value reaches the cache.
+ ///
+ public string? RefreshToken { get; set; } = "refresh-token-1";
+
///
public void Dispose()
{
diff --git a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
index 78482ca..5b3ac18 100644
--- a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
+++ b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
@@ -1,4 +1,5 @@
using DodoSSH.Client.App.ViewModels;
+using DodoSSH.Client.Auth;
using DodoSSH.Client.Session;
// FakeDeviceKeyStore is compiled into this assembly from a source link and keeps its original namespace;
@@ -42,6 +43,18 @@ public sealed class ShellFlowTests : IAsyncLifetime
private int signInAttempts;
+ /// How many times a shell has tried to resume a remembered sign-in, and with what.
+ ///
+ /// Counted rather than merely allowed, because the interesting assertions about resuming are about how
+ /// often it happens: once per launch when it works, and never again once the provider has refused.
+ ///
+ private int resumeAttempts;
+
+ private string? resumedWith;
+
+ /// When set, resuming throws — how a revoked or rotated-away token is exercised.
+ private Exception? resumeFailure;
+
private string directory = null!;
private ClientPaths paths = null!;
private ClientCacheFactory caches = null!;
@@ -110,7 +123,8 @@ public sealed class ShellFlowTests : IAsyncLifetime
deviceKeys,
SignInAsync,
TimeProvider.System,
- CheapProfile);
+ CheapProfile,
+ ResumeAsync);
return ValueTask.CompletedTask;
}
@@ -1966,7 +1980,8 @@ public sealed class ShellFlowTests : IAsyncLifetime
await AddHostAsync(vault, "prod-web-01");
await AddHostAsync(vault, "prod-web-02");
- vault.SelectedHost = vault.Hosts.Single(host => host.Label == "prod-web-01");
+ vault.SelectedHost = vault.Hosts.Single(
+ host => string.Equals(host.Label, "prod-web-01", StringComparison.Ordinal));
vault.HostFilter = "prod";
@@ -1999,7 +2014,8 @@ public sealed class ShellFlowTests : IAsyncLifetime
await AddHostAsync(vault, "prod-db");
await AddHostAsync(vault, "stage-web");
- vault.SelectedHost = vault.Hosts.Single(host => host.Label == "stage-web");
+ vault.SelectedHost = vault.Hosts.Single(
+ host => string.Equals(host.Label, "stage-web", StringComparison.Ordinal));
await vault.LoadAsync(Token);
@@ -2027,6 +2043,45 @@ public sealed class ShellFlowTests : IAsyncLifetime
: Task.FromResult(server);
}
+ ///
+ /// Counted and recorded, and never a browser: resuming is the path that must reach the token endpoint
+ /// and nothing else. stands in for a provider that refuses.
+ ///
+ private Task ResumeAsync(
+ Uri serverUrl,
+ string refreshToken,
+ CancellationToken cancellationToken)
+ {
+ resumeAttempts++;
+ resumedWith = refreshToken;
+
+ return resumeFailure is { } failure
+ ? Task.FromException(failure)
+ : Task.FromResult(server);
+ }
+
+ ///
+ /// A second shell over the same profile directory, as a relaunch of the application is.
+ ///
+ ///
+ /// Its sign-in delegate throws by default, which is the assertion rather than a convenience: a launch
+ /// that reached it would be one that opened a browser at somebody, and every test using this is about
+ /// a launch that must not.
+ ///
+ private MainWindowViewModel Relaunch(
+ IDeviceKeyStore? keys = null,
+ MainWindowViewModel.ResumeHandler? resume = null) =>
+ new(
+ paths,
+ caches,
+ workspace,
+ new VaultKnownHostStore(),
+ keys ?? new UnavailableDeviceKeyStore(),
+ (_, _) => throw new InvalidOperationException("The shell opened a browser on launch."),
+ TimeProvider.System,
+ CheapProfile,
+ resume);
+
private async Task SignedInAsync()
{
await shell.StartAsync(Token);
@@ -2210,6 +2265,296 @@ public sealed class ShellFlowTests : IAsyncLifetime
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
}
+ // ---- Staying signed in, and syncing on its own ----
+
+ ///
+ /// The behaviour the whole remembered-sign-in mechanism exists for. Before it, a machine that had been
+ /// set up launched offline and stayed there until somebody found the SIGN IN button on the
+ /// preferences screen — so the sync loop ran once a minute against nothing, and a colleague's change
+ /// arrived when a user happened to go looking for it.
+ ///
+ [Fact]
+ public async Task ARelaunchComesBackOnlineWithoutOpeningABrowser()
+ {
+ await UnlockedAsync();
+
+ // The pass that remembers the sign-in. It is the one the loop runs when the vault opens; driven
+ // here rather than raced against.
+ await shell.Vault!.SyncOnOpenAsync(Token);
+
+ await shell.LockCommand.ExecuteAsync(null);
+
+ var relaunch = Relaunch(resume: ResumeAsync);
+ await using var _ = relaunch.ConfigureAwait(false);
+
+ await relaunch.StartAsync(Token);
+
+ relaunch.State.ShouldBe(ShellState.Locked);
+ relaunch.IsOnline.ShouldBeFalse(
+ "the token is sealed under the vault's key, so a locked machine cannot reach the server");
+ resumeAttempts.ShouldBe(0);
+
+ relaunch.Passphrase = Passphrase;
+ await relaunch.UnlockCommand.ExecuteAsync(null);
+
+ await relaunch.Vault!.SyncOnOpenAsync(Token);
+
+ relaunch.IsOnline.ShouldBeTrue();
+ resumedWith.ShouldBe("refresh-token-1");
+ signInAttempts.ShouldBe(1, "the browser opened once, at setup, and must not open again");
+ }
+
+ ///
+ /// Providers rotate refresh tokens on use, and a client that persisted only the first one it saw would
+ /// present a retired token on the next launch and be signed out for no visible reason. This is the one
+ /// failure in the mechanism that would look like flakiness rather than a bug.
+ ///
+ [Fact]
+ public async Task ARotatedTokenIsTheOneTheNextLaunchPresents()
+ {
+ await UnlockedAsync();
+ await shell.Vault!.SyncOnOpenAsync(Token);
+
+ server.RefreshToken = "refresh-token-2";
+ await shell.Vault.SyncOnOpenAsync(Token);
+
+ await shell.LockCommand.ExecuteAsync(null);
+
+ var relaunch = Relaunch(resume: ResumeAsync);
+ await using var _ = relaunch.ConfigureAwait(false);
+
+ await relaunch.StartAsync(Token);
+ relaunch.Passphrase = Passphrase;
+ await relaunch.UnlockCommand.ExecuteAsync(null);
+
+ await relaunch.Vault!.SyncOnOpenAsync(Token);
+
+ resumedWith.ShouldBe("refresh-token-2");
+ }
+
+ [Fact]
+ public async Task ARefusedSignIn_IsSaidOnceAndNotRetriedForever()
+ {
+ await UnlockedAsync();
+ await shell.Vault!.SyncOnOpenAsync(Token);
+ await shell.LockCommand.ExecuteAsync(null);
+
+ // What a revoked session, or a rotation this machine missed, looks like from the token endpoint.
+ resumeFailure = new OidcException(
+ "The token endpoint returned 400: Invalid refresh token.", "invalid_grant");
+
+ var relaunch = Relaunch(resume: ResumeAsync);
+ await using var _ = relaunch.ConfigureAwait(false);
+
+ await relaunch.StartAsync(Token);
+ relaunch.Passphrase = Passphrase;
+ await relaunch.UnlockCommand.ExecuteAsync(null);
+
+ // The vault opens regardless: nothing about being signed out stops a passphrase working.
+ relaunch.State.ShouldBe(ShellState.Unlocked, relaunch.StatusMessage);
+
+ await relaunch.Vault!.SyncOnOpenAsync(Token);
+
+ relaunch.IsOnline.ShouldBeFalse();
+ relaunch.Vault.Status.ShouldContain("expired", Case.Insensitive);
+
+ var attempted = resumeAttempts;
+ attempted.ShouldBeGreaterThan(0);
+
+ // And the token is dropped rather than retried once a minute for the life of the profile.
+ await relaunch.Vault.SyncOnOpenAsync(Token);
+
+ resumeAttempts.ShouldBe(attempted);
+ }
+
+ ///
+ /// The pass that runs when the vault opens used to be skipped in the application and nowhere else: the
+ /// loop is started from inside the unlock command, so the busy flag it yields to was raised by the
+ /// unlock itself. It cost a full minute of a machine that was online and out of date, and no test saw
+ /// it because every test called the pass by hand with nothing busy.
+ ///
+ [Fact]
+ public async Task ThePassOnOpen_RunsEvenThoughUnlockingIsStillBusy()
+ {
+ await UnlockedAsync();
+ var vault = shell.Vault!;
+
+ server.SyncFailure = new HttpRequestException("The server is having a bad day.");
+ await AddHostAsync(vault, "prod-db");
+ server.SyncFailure = null;
+
+ vault.PendingChanges.ShouldBe(1, "there must be something to push for this to mean anything");
+
+ // Standing in for the unlock command that is still running when the loop starts its first pass.
+ vault.IsBusy = true;
+
+ await vault.SyncOnOpenAsync(Token);
+
+ vault.PendingChanges.ShouldBe(0, "the pass on open does not yield to the unlock that started it");
+ server.LiveRowCount.ShouldBe(1);
+
+ vault.IsBusy = false;
+ }
+
+ // ---- Signing out ----
+
+ [Fact]
+ public async Task SigningOutIsAQuestionFirst()
+ {
+ await UnlockedAsync();
+
+ shell.SignOutCommand.Execute(null);
+
+ shell.IsConfirmingSignOut.ShouldBeTrue();
+ shell.IsAskingForThePassphrase.ShouldBeFalse("the two cards swap rather than stack");
+ shell.State.ShouldBe(ShellState.Unlocked, "arming the question changes nothing else");
+ shell.Vault.ShouldNotBeNull();
+
+ shell.CancelSignOutCommand.Execute(null);
+
+ shell.IsConfirmingSignOut.ShouldBeFalse();
+ shell.State.ShouldBe(ShellState.Unlocked);
+ shell.Vault.ShouldNotBeNull("cancelling must not have closed anything");
+ }
+
+ [Fact]
+ public async Task SigningOut_DeletesThisMachinesCopyAndLeavesTheVaultOnTheServer()
+ {
+ await UnlockedAsync();
+ await AddHostAsync(shell.Vault!, "prod-db");
+
+ server.LiveRowCount.ShouldBe(1);
+
+ shell.SignOutCommand.Execute(null);
+ await shell.ConfirmSignOutCommand.ExecuteAsync(null);
+
+ shell.State.ShouldBe(ShellState.NeedsServer);
+ shell.Vault.ShouldBeNull("the vault's keys are gone");
+ shell.IsOnline.ShouldBeFalse("and so is the connection");
+ shell.AccountName.ShouldBeNull();
+ shell.IsConfirmingSignOut.ShouldBeFalse();
+
+ server.LiveRowCount.ShouldBe(1, "the vault lives on the server and signing out does not touch it");
+
+ // A relaunch finds a machine that has never been set up, which is what "reset" has to mean.
+ var relaunch = Relaunch();
+ await using var _ = relaunch.ConfigureAwait(false);
+
+ await relaunch.StartAsync(Token);
+
+ relaunch.State.ShouldBe(ShellState.NeedsServer);
+ relaunch.AccountName.ShouldBeNull();
+ }
+
+ ///
+ /// The half that makes signing out a reset rather than a wipe: the cache is emptied and immediately
+ /// usable, so setting the machine up again needs no restart. It is also the way back for somebody who
+ /// has forgotten their passphrase, which is why the button is on the unlock screen too.
+ ///
+ [Fact]
+ public async Task AfterSigningOut_TheSameApplicationCanBeSetUpAgain()
+ {
+ await UnlockedAsync();
+
+ shell.SignOutCommand.Execute(null);
+ await shell.ConfirmSignOutCommand.ExecuteAsync(null);
+
+ await shell.SignInCommand.ExecuteAsync(null);
+
+ // The account is already enrolled — this machine forgot it, the server did not — so the wrap and
+ // the salt are cached again from /me and the old passphrase still opens them.
+ shell.State.ShouldBe(ShellState.Locked, shell.StatusMessage);
+
+ shell.Passphrase = Passphrase;
+ await shell.UnlockCommand.ExecuteAsync(null);
+
+ shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
+ shell.Vault.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public async Task SigningOutWithQueuedChanges_SaysHowManyWillBeLost()
+ {
+ await UnlockedAsync();
+ var vault = shell.Vault!;
+
+ // A change this machine made and could not send is the one thing signing out destroys that
+ // nothing else has a copy of, so the count is the whole point of the confirmation.
+ server.SyncFailure = new HttpRequestException("The server is having a bad day.");
+ await AddHostAsync(vault, "prod-db");
+
+ vault.PendingChanges.ShouldBe(1);
+
+ shell.SignOutCommand.Execute(null);
+
+ shell.SignOutWarning.ShouldContain("1 change");
+ shell.SignOutWarning.ShouldContain("lost");
+ }
+
+ [Fact]
+ public async Task SigningOutWhileLocked_AdmitsItCannotCountWhatWouldBeLost()
+ {
+ await UnlockedAsync();
+ await shell.LockCommand.ExecuteAsync(null);
+
+ shell.SignOutCommand.Execute(null);
+
+ // The outbox is sealed under the key the vault holds, so a locked machine genuinely cannot count
+ // it. Saying "nothing will be lost" here would be a claim this state cannot support.
+ shell.SignOutWarning.ShouldContain("cannot be counted");
+ }
+
+ [Fact]
+ public async Task SigningOut_WithdrawsThisMachineFromTheAccount()
+ {
+ // The leftover ADR 0007 is about: a device wrap on the account whose private half has just been
+ // deleted is one nobody can account for and nothing can use.
+ await UnlockedAsync();
+ await shell.RegisterDeviceCommand.ExecuteAsync(null);
+
+ server.RegisteredDevices.Count.ShouldBe(1);
+
+ shell.SignOutCommand.Execute(null);
+ await shell.ConfirmSignOutCommand.ExecuteAsync(null);
+
+ server.RegisteredDevices.ShouldBeEmpty();
+ deviceKeys.Peek().ShouldBeNull("this machine's own copy of the key goes too");
+
+ var relaunch = Relaunch(keys: deviceKeys);
+ await using var _ = relaunch.ConfigureAwait(false);
+
+ await relaunch.StartAsync(Token);
+
+ relaunch.CanUnlockWithDevice.ShouldBeFalse();
+ }
+
+ ///
+ /// Signing out is the strongest thing this application does to itself, and it deliberately does not do
+ /// the one thing locking refuses to do either. The argument is the same one LockAsync carries:
+ /// a session that authenticated before is still running somebody's job, and a button that destroyed it
+ /// would be a button people stop pressing.
+ ///
+ [Fact]
+ public async Task SigningOut_LeavesOpenShellsRunningAndSaysSo()
+ {
+ await UnlockedAsync();
+
+ await workspace.OpenSessionAsync(
+ new SshConnectionRequest("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant")),
+ TerminalSize.Default,
+ Token);
+
+ shell.SignOutCommand.Execute(null);
+
+ shell.HasLiveSessions.ShouldBeTrue();
+ shell.LiveSessionSummary.ShouldBe("1 shell is still connected and still running.");
+
+ await shell.ConfirmSignOutCommand.ExecuteAsync(null);
+
+ workspace.LiveSessionCount.ShouldBe(1);
+ shell.State.ShouldBe(ShellState.NeedsServer);
+ }
+
private async Task UnlockedAsync()
{
await EnrolledAndConfirmedAsync();
diff --git a/tests/DodoSSH.Client.Storage.Tests/RememberedSignInTests.cs b/tests/DodoSSH.Client.Storage.Tests/RememberedSignInTests.cs
new file mode 100644
index 0000000..9b202de
--- /dev/null
+++ b/tests/DodoSSH.Client.Storage.Tests/RememberedSignInTests.cs
@@ -0,0 +1,124 @@
+using DodoSSH.Contracts;
+
+namespace DodoSSH.Client.Storage.Tests;
+
+///
+/// The sign-in a machine may resume, and what emptying the cache does to it.
+///
+///
+/// Two behaviours meet here for a reason: the refresh token is the one thing in this cache that is a
+/// credential for the account rather than for the vault, so both halves of its life — sealed
+/// while it is kept, gone when the user signs out — belong under one test class.
+///
+public sealed class RememberedSignInTests
+{
+ private static CancellationToken Token => TestContext.Current.CancellationToken;
+
+ [Fact]
+ public async Task ARememberedTokenRoundTrips()
+ {
+ using var harness = await CacheHarness.CreateAsync();
+
+ var store = Store(harness);
+
+ (await store.ReadAsync(Token)).ShouldBeNull("nothing has been remembered yet");
+
+ await store.SaveAsync("refresh-token-1", Token);
+
+ (await store.ReadAsync(Token)).ShouldBe("refresh-token-1");
+ }
+
+ [Fact]
+ public async Task RememberingAgain_ReplacesRatherThanAdds()
+ {
+ // What a rotating provider does on every refresh. A second row would be a constraint violation;
+ // keeping the first would leave the next launch presenting a token the provider has retired.
+ using var harness = await CacheHarness.CreateAsync();
+
+ var store = Store(harness);
+
+ await store.SaveAsync("refresh-token-1", Token);
+ await store.SaveAsync("refresh-token-2", Token);
+
+ (await store.ReadAsync(Token)).ShouldBe("refresh-token-2");
+ }
+
+ [Fact]
+ public async Task AnotherUsersCacheKey_DoesNotOpenIt()
+ {
+ // The whole reason this is sealed rather than stored. A cache file lifted off a machine cannot be
+ // made to yield an account credential without the key that only an unlocked vault holds.
+ using var owner = await CacheHarness.CreateAsync();
+ using var stranger = await CacheHarness.CreateAsync();
+
+ await Store(owner).SaveAsync("refresh-token-1", Token);
+
+ var strangersView = new RememberedSignInStore(
+ owner.Factory, stranger.Protector, CacheHarness.UserId, TimeProvider.System);
+
+ (await strangersView.ReadAsync(Token)).ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task ForgettingIt_LeavesNothingToResume()
+ {
+ using var harness = await CacheHarness.CreateAsync();
+
+ var store = Store(harness);
+
+ await store.SaveAsync("refresh-token-1", Token);
+ await store.ForgetAsync(Token);
+
+ (await store.ReadAsync(Token)).ShouldBeNull();
+
+ // And forgetting what is not there is not an error: it runs on a sign-out from a machine that
+ // never remembered one.
+ await store.ForgetAsync(Token);
+ }
+
+ [Fact]
+ public async Task ResettingTheCache_EmptiesEveryTableAndKeepsTheSchema()
+ {
+ // What signing out does on disk. Every row goes — the profile an unlock reads, the item mirror,
+ // the outbox, the remembered sign-in — and the database is immediately usable again, because the
+ // application has to be able to be set up afresh without being restarted.
+ using var harness = await CacheHarness.CreateAsync();
+
+ var entityId = Guid.CreateVersion7();
+
+ await harness.Unlock.SaveAsync(Material(), Token);
+ await harness.Items.SaveAsync(CacheHarness.Item(entityId), Token);
+ await harness.Outbox.QueueAsync(CacheHarness.Change(entityId), Token);
+ await Store(harness).SaveAsync("refresh-token-1", Token);
+
+ await harness.Factory.ResetAsync(Token);
+
+ (await harness.Unlock.ReadAsync(Token)).ShouldBeNull("the profile is what makes a machine enrolled");
+
+ (await harness.Items
+ .ListAsync(CacheHarness.VaultId, SyncEntityType.Host, includeDeleted: true, Token))
+ .ShouldBeEmpty();
+ (await harness.Outbox.ListAllAsync(CacheHarness.VaultId, Token)).ShouldBeEmpty();
+ (await Store(harness).ReadAsync(Token)).ShouldBeNull();
+
+ // Usable, not merely empty: writing to it again must not need a migration.
+ await harness.Unlock.SaveAsync(Material(), Token);
+ (await harness.Unlock.ReadAsync(Token)).ShouldNotBeNull();
+ }
+
+ private static RememberedSignInStore Store(CacheHarness harness) =>
+ new(harness.Factory, harness.Protector, CacheHarness.UserId, TimeProvider.System);
+
+ private static StoredUnlockMaterial Material() =>
+ new(
+ "https://dodossh.example",
+ CacheHarness.UserId,
+ "https://idp.example",
+ "alice",
+ "alice@example.com",
+ "Alice",
+ KeyGeneration: 1,
+ WrappedPrivateKey: [1, 2, 3, 4],
+ new KdfParameters("argon2id", [5, 6, 7, 8], 262144, 4, 1),
+ DateTimeOffset.FromUnixTimeSeconds(1_750_000_000));
+}