Public Access
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:
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user