Stay signed in, come back online by itself, and let a machine be given up

Three things a machine that has been set up could not do. Unlock now takes
Enter, which is the gesture everybody makes after typing a password and which
did nothing until they found the button.

Signing in survives a relaunch. The refresh token is kept in the local cache,
sealed under the vault's own cache key, so a later launch resumes the session
through the refresh grant with no browser and nobody present — and because it
is sealed under that key, only an unlocked vault can resume it. A locked
client therefore cannot reach the server at all, which is a consequence worth
stating rather than working around; docs/crypto.md §3.2 records it. Every sync
pass asks the shell for a connection rather than reading one captured at
unlock, so a laptop that unlocked on a train is online within a minute of
finding a network, with nothing pressed. Unlocking itself still never waits on
a socket.

Signing out empties this machine: the profile, the cached items, the outbox
and this machine's device key, with the account's row withdrawn when the
server can be reached. It asks first and says what it costs — the outbox count
when the vault is open, an admission that it cannot be counted when it is not,
and the shells that keep running either way. The vault is on the server and is
untouched, which is what makes the same button the only honest answer to a
forgotten passphrase, so it is on the unlock screen as well as in preferences.
It cannot end the session at the identity provider, and says so.

Two defects surfaced on the way. The synchronisation pass that runs when the
vault opens never ran at all: the loop is started from inside the unlock
command, so the busy flag it yields to was raised by that command — the first
sync was a minute late on every launch. And signing in from preferences while
unlocked threw an unlock screen over an open vault whose keys were still in
memory.

The unlock card and the new confirmation live in their own controls because
MainWindow cannot be laid out headless, so markup left inside it is markup no
test can measure; both are now measured at the window's minimum size in the
shapes that grow. What is still unverified is the composed window itself.
This commit is contained in:
2026-07-31 11:07:36 +02:00
parent 94e11f5e38
commit 0b261c4d39
28 changed files with 2323 additions and 80 deletions
+130 -7
View File
@@ -41,32 +41,49 @@ internal sealed class RefreshingAccessTokenProvider(
private readonly SemaphoreSlim gate = new(1, 1);
private TokenSet tokens = initial;
/// <summary>
/// The refresh token this provider currently holds, or null when none was granted.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal string? RefreshToken => Volatile.Read(ref tokens).RefreshToken;
public async ValueTask<string> 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();
}
/// <summary>
/// Refuses to open anything, for the flows that must never reach a browser.
/// </summary>
/// <remarks>
/// <see cref="ServerConnection.ResumeAsync"/> uses only the refresh grant, which needs no user agent —
/// but <see cref="OidcClient"/> 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.
/// </remarks>
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.");
}
/// <summary>
/// What a signed-in server offers, as everything above the session layer needs it.
/// </summary>
@@ -102,6 +139,18 @@ public interface IVaultServer : IDisposable
/// <summary>Sync tuning derived from what this server actually accepts.</summary>
SyncOptions SyncOptions { get; }
/// <summary>
/// The refresh token this connection holds right now, or null when the provider granted none.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
string? RefreshToken { get; }
}
/// <summary>
@@ -182,6 +231,9 @@ public sealed class ServerConnection : IVaultServer
MaxOperationsPerPush = Math.Clamp(Meta.MaxOperationsPerPush, 1, 500),
};
/// <inheritdoc />
public string? RefreshToken => tokens.RefreshToken;
/// <summary>
/// Discovers the server, signs the user in through their browser, and returns the connection.
/// </summary>
@@ -232,6 +284,77 @@ public sealed class ServerConnection : IVaultServer
}
}
/// <summary>
/// Re-establishes a connection from a remembered refresh token, with no browser and no user present.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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
/// <c>/.well-known/dodossh-configuration</c> at every connection, so a deployment that moves its
/// identity provider does not leave every client pinned to the old one.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
/// <param name="serverUrl">The server this profile is enrolled against.</param>
/// <param name="refreshToken">The remembered token.</param>
/// <param name="clock">Time source, for token expiry.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public static async Task<ServerConnection> 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;
}
}
/// <inheritdoc />
public void Dispose()
{