Public Access
Wire the Avalonia shell to the vault
The host list now comes from the vault instead of from a form. A fresh machine takes a server URL, signs in through the browser, enrolls, and from then on opens with the passphrase alone. DodoSSH.Client.Session is the composition layer: where a profile lives, how it unlocks, and how a machine gets one. ClientPaths picks a non-roaming per-OS directory — %LOCALAPPDATA% and never %APPDATA%, because a SQLite cache that roams between two machines is a corrupt one, and each machine's outbox is its own. SessionOpener needs no transport at all and could not reach one if it wanted to; that is the offline unlock, asserted rather than asserted about. A wrong passphrase, a stale KDF and a grant revoked by a rekey are three different answers, because the remedies are three different things and telling someone to retype a passphrase that was never the problem is worse than saying nothing. The shell's states are the onboarding story. The recovery code gets its own state that cannot be clicked past: it exists for one moment, losing it with the passphrase loses the vault, and there is no server-side reset by design. It is dropped from memory on confirmation rather than merely hidden. Sign-in is a delegate over IVaultServer, so the whole state machine runs in a test against an in-memory server — no browser, no identity provider, no toolkit. The view models are plain observable objects, which is what makes that possible. What it does not cover is whether the XAML binds to the right names; that needs a rendered tree and Avalonia.Headless, and is its own piece of work. Three things found by doing it rather than by reading it: - Pooled SQLite connections keep the database file open after the last context is disposed. On Windows that means locked, so the application could never replace its own cache — and a test could not clean up after itself, which is how it surfaced. Dispose now clears the pool. - EF's SQLite provider puts the database in WAL mode, so the cache is three files. A comment in ClientCacheFactory claimed the opposite; reading PRAGMA journal_mode off a real launch settled it. WAL is the right mode here — a sync pass writes while the interface reads — so the comment was wrong on the merits as well as on the fact. - Enrolling a device key with nowhere to keep the private half would put a wrap on the server nobody can open and make the device list claim this machine can unlock without a passphrase. Device binding is now optional and the shell declines it until the OS keystore is wired. Verified on Windows: the client created %LOCALAPPDATA%\DodoSSH\cache.db and migrated it on first launch, and msedgewebview2 held an established connection to the data plane while the unlock overlay covered it — which is the point of covering the WebView rather than collapsing it, since a NativeWebView that is never laid out is never realised. 630 tests, up from 593. The recovery-code gate and the offline unlock were each verified by breaking them and watching the right test fail. Still to do for M1's actual definition of done: the manual run against the real API and a real Keycloak. Credentials are not a synced entity type yet, so a connection still asks for a password, and the interface says so rather than implying otherwise.
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
using DodoSSH.Client.Api;
|
||||
using DodoSSH.Client.Auth;
|
||||
using DodoSSH.Client.Sync;
|
||||
using DodoSSH.Contracts;
|
||||
|
||||
namespace DodoSSH.Client.Session;
|
||||
|
||||
/// <summary>
|
||||
/// Stands in for a token provider before anyone has signed in.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Discovery has to happen before authentication is possible — a client cannot know how to authenticate
|
||||
/// until it has asked — but the API client takes a token provider in its constructor. Rather than make
|
||||
/// that provider mutable and hope no authenticated call slips through early, the unauthenticated phase
|
||||
/// gets a provider that says exactly what went wrong.
|
||||
/// </remarks>
|
||||
internal sealed class UnavailableAccessTokenProvider : IAccessTokenProvider
|
||||
{
|
||||
internal static UnavailableAccessTokenProvider Instance { get; } = new();
|
||||
|
||||
public ValueTask<string> GetAccessTokenAsync(CancellationToken cancellationToken) =>
|
||||
throw new InvalidOperationException(
|
||||
"An authenticated call was attempted before sign-in. Only /meta and the discovery document "
|
||||
+ "are reachable at this point.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the bearer token fresh for the life of a connection.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The refresh happens under a lock with the expiry re-checked inside it. Without that second check,
|
||||
/// several concurrent calls all decide the token is stale and all refresh — and because many providers
|
||||
/// rotate the refresh token on use, every attempt after the first fails, turning one expiry into a forced
|
||||
/// re-authentication.
|
||||
/// </remarks>
|
||||
internal sealed class RefreshingAccessTokenProvider(
|
||||
OidcClient oidc,
|
||||
TokenSet initial,
|
||||
TimeProvider clock) : IAccessTokenProvider, IDisposable
|
||||
{
|
||||
private readonly SemaphoreSlim gate = new(1, 1);
|
||||
private TokenSet tokens = initial;
|
||||
|
||||
public async ValueTask<string> GetAccessTokenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!tokens.NeedsRefresh(clock))
|
||||
{
|
||||
return tokens.AccessToken;
|
||||
}
|
||||
|
||||
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
if (!tokens.NeedsRefresh(clock))
|
||||
{
|
||||
return tokens.AccessToken;
|
||||
}
|
||||
|
||||
if (tokens.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)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return tokens.AccessToken;
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => gate.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What a signed-in server offers, as everything above the session layer needs it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An interface rather than the concrete connection, for one specific reason: establishing a real one
|
||||
/// requires discovery, a browser and a token exchange. A shell that depended on the concrete type would
|
||||
/// make its own state machine — sign in, enroll, unlock, sync — reachable only by clicking through an
|
||||
/// identity provider, which is the part of an application that most needs a test and least often has one.
|
||||
/// </remarks>
|
||||
public interface IVaultServer : IDisposable
|
||||
{
|
||||
/// <summary>The server this is connected to.</summary>
|
||||
Uri ServerUrl { get; }
|
||||
|
||||
/// <summary>Who am I, and publish my first key.</summary>
|
||||
IAccountApi Account { get; }
|
||||
|
||||
/// <summary>Pull and push.</summary>
|
||||
ISyncApi Sync { get; }
|
||||
|
||||
/// <summary>Obtains the identity provider's signature over a key statement.</summary>
|
||||
IKeyBindingAuthorizer KeyBinding { get; }
|
||||
|
||||
/// <summary>Sync tuning derived from what this server actually accepts.</summary>
|
||||
SyncOptions SyncOptions { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A signed-in connection to one DodoSSH server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The onboarding story in one object: the user types a server URL, the client reads
|
||||
/// <c>/.well-known/dodossh-configuration</c> to learn the identity provider, the client id and the
|
||||
/// scopes, and everything else follows. Nothing about the identity provider is configured on this
|
||||
/// machine.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A session outlives this. Losing the network invalidates the connection, not the vault — which is why
|
||||
/// syncing takes an <see cref="ISyncApi"/> per call rather than the session holding one.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ServerConnection : IVaultServer
|
||||
{
|
||||
private readonly HttpClient http;
|
||||
private readonly RefreshingAccessTokenProvider tokens;
|
||||
private bool disposed;
|
||||
|
||||
private ServerConnection(
|
||||
Uri serverUrl,
|
||||
HttpClient http,
|
||||
DodoSshConfiguration configuration,
|
||||
MetaResponse meta,
|
||||
OidcClient oidc,
|
||||
RefreshingAccessTokenProvider tokens,
|
||||
DodoSshApiClient api)
|
||||
{
|
||||
ServerUrl = serverUrl;
|
||||
this.http = http;
|
||||
Configuration = configuration;
|
||||
Meta = meta;
|
||||
Oidc = oidc;
|
||||
this.tokens = tokens;
|
||||
Api = api;
|
||||
}
|
||||
|
||||
/// <summary>The server this is connected to.</summary>
|
||||
public Uri ServerUrl { get; }
|
||||
|
||||
/// <summary>What the server told us about itself and its identity provider.</summary>
|
||||
public DodoSshConfiguration Configuration { get; }
|
||||
|
||||
/// <summary>Versions, features and limits.</summary>
|
||||
public MetaResponse Meta { get; }
|
||||
|
||||
/// <summary>The identity provider client, which is also the key-binding authorizer.</summary>
|
||||
public OidcClient Oidc { get; }
|
||||
|
||||
/// <summary>The authenticated API client.</summary>
|
||||
public DodoSshApiClient Api { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAccountApi Account => Api;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ISyncApi Sync => Api;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IKeyBindingAuthorizer KeyBinding => Oidc;
|
||||
|
||||
/// <summary>
|
||||
/// Sync tuning derived from what this server actually accepts.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is what capability negotiation is for, and why there is no URL API version. A client and a
|
||||
/// server that upgrade independently — normal for self-hosted software — have to agree on limits by
|
||||
/// asking rather than by assuming. Sending a batch larger than the server's cap would have the whole
|
||||
/// push rejected rather than the excess trimmed.
|
||||
/// </remarks>
|
||||
/// <inheritdoc cref="IVaultServer.SyncOptions" />
|
||||
public SyncOptions SyncOptions => new()
|
||||
{
|
||||
MaxOperationsPerPush = Math.Clamp(Meta.MaxOperationsPerPush, 1, 500),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Discovers the server, signs the user in through their browser, and returns the connection.
|
||||
/// </summary>
|
||||
/// <param name="serverUrl">The DodoSSH server's base URL — the only thing the user has to know.</param>
|
||||
/// <param name="browser">Opens the system browser. Never an embedded one; see RFC 8252.</param>
|
||||
/// <param name="clock">Time source, for token expiry.</param>
|
||||
/// <param name="cancellationToken">Cancels the wait for the browser.</param>
|
||||
public static async Task<ServerConnection> SignInAsync(
|
||||
Uri serverUrl,
|
||||
IBrowserLauncher browser,
|
||||
TimeProvider clock,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(serverUrl);
|
||||
ArgumentNullException.ThrowIfNull(browser);
|
||||
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, browser, clock, BuildOidcOptions(configuration));
|
||||
|
||||
var tokenSet = await oidc.SignInAsync(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()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
tokens.Dispose();
|
||||
http.Dispose();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// HTTPS is required for the provider's metadata unless the authority is loopback, which is what a
|
||||
/// development Keycloak looks like. A configuration flag would be the alternative and a worse one:
|
||||
/// it would be set once during development and never unset. Loopback is not a weaker channel — it
|
||||
/// never leaves the machine — so the exemption is narrow and does not need a switch.
|
||||
/// </remarks>
|
||||
private static OidcClientOptions BuildOidcOptions(DodoSshConfiguration configuration) =>
|
||||
new()
|
||||
{
|
||||
Authority = configuration.Oidc.Authority,
|
||||
ClientId = configuration.Oidc.ClientId,
|
||||
Scopes = configuration.Oidc.Scopes,
|
||||
RequireHttpsMetadata = !configuration.Oidc.Authority.IsLoopback,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user