Files
DodoSSH/src/DodoSSH.Client.Session/ServerConnection.cs
T
laurensandClaude Opus 5.5 ef365f462e Spelunk: sign each device in through the browser instead of keeping Spelunk API keys in the vault
A Spelunk API key reaches one organisation, so a server elsewhere - oc-linux in Orange Climate - was
refused before it reached the agent. Each device now signs in to a Spelunk once, under Settings ->
Security -> Spelunk or from a Spelunk host's editor: the OAuth authorization code flow with PKCE for a
native app (RFC 8252), with Spelunk as the authorization server. The browser opens Spelunk's
/api/app-sign-in/authorize with a loopback redirect (the app's own scheme on Android), the person signs
in there however they normally do and allows "DodoSSH on <device>", and the code is redeemed at
/api/app-sign-in/token for an app session token. That token opens terminals in every organisation its
person is in, with the role they have on each server at the time; tunnel tickets send no organisation.

The sign-in stays on the device: sealed in the local cache under the LocalCacheKey (AAD resource type
18, bound to its row), never synced, wiped with the cache, and only ever sent to the Spelunk it came
from. A 401 from Spelunk forgets it; SIGN OUT ends it there and here, and so does signing out of
DodoSSH. Before every Spelunk connection DodoSSH asks its own identity provider whether its sign-in
still stands (a forced refresh, at most once a minute), and an account it refuses is signed out of
DodoSSH and of every Spelunk on the device.

A route names no key any more: Spelunk hosts are written at host schema 11, which an older build shows
read-only and asks to be updated for, and a payload that still names a key credential decodes with the
id skipped. The SpelunkKey item type is gone: SyncEntityType 14, ChangeEntityType 14 and AAD type 17
are retired, the server drops spelunk_key and its change-log rows, and the client cache drops its
cached items, outbox entries and conflicts of that type. A link still never starts a sign-in: from a
Spelunk this device is not signed in to it opens the editor, which offers to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:26:25 +02:00

528 lines
21 KiB
C#

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;
private DateTimeOffset lastRefreshedAt = DateTimeOffset.MinValue;
/// <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)
{
var current = Volatile.Read(ref tokens);
if (!current.NeedsRefresh(clock))
{
return current.AccessToken;
}
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
current = Volatile.Read(ref tokens);
if (!current.NeedsRefresh(clock))
{
return current.AccessToken;
}
if (current.RefreshToken is null)
{
throw new InvalidOperationException(
"The access token has expired and no refresh token was granted. Sign in again.");
}
var refreshed = await oidc.RefreshAsync(current.RefreshToken, cancellationToken)
.ConfigureAwait(false);
Volatile.Write(ref tokens, refreshed);
lastRefreshedAt = clock.GetUtcNow();
return refreshed.AccessToken;
}
finally
{
gate.Release();
}
}
/// <summary>
/// Refreshes now, whatever the expiry says, unless that was done within <paramref name="within"/>.
/// </summary>
/// <remarks>
/// The refresh grant is the one question the identity provider answers about whether a sign-in still
/// stands: a disabled account, a revoked session or a removed user gets <c>invalid_grant</c> here, where
/// an access token already issued would go on being accepted until it expired. Under the same lock as the
/// ordinary refresh, so the two cannot spend one rotating refresh token twice.
/// </remarks>
/// <exception cref="OidcException">The provider refused, or could not be asked.</exception>
internal async Task RefreshNowAsync(TimeSpan within, CancellationToken cancellationToken)
{
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var now = clock.GetUtcNow();
if (now - lastRefreshedAt < within)
{
return;
}
var current = Volatile.Read(ref tokens);
if (current.RefreshToken is null)
{
throw new OidcException("This sign-in has no refresh token, so it cannot be checked. Sign in again.", "invalid_grant");
}
var refreshed = await oidc.RefreshAsync(current.RefreshToken, cancellationToken).ConfigureAwait(false);
Volatile.Write(ref tokens, refreshed);
lastRefreshedAt = now;
}
finally
{
gate.Release();
}
}
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>
/// <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>Teams, their members, and the vaults they own.</summary>
ITeamApi Teams { get; }
/// <summary>
/// The public-key directory, and the key log that makes an answer from it checkable.
/// </summary>
/// <remarks>
/// Exposed as one member because the two are only ever used together: a directory answer is a claim
/// the server makes about somebody else's key, and the log is what turns it into something a client
/// can verify. See <c>KeyLogAudit</c>.
/// </remarks>
IDirectoryApi Directory { get; }
/// <summary>Vault key grants: who can open a vault, and who let them.</summary>
IVaultGrantApi Grants { get; }
/// <summary>
/// Notices that something changed, so a synchronisation need not wait for the timer.
/// </summary>
/// <remarks>
/// Always present, never null: a server that does not offer the feature — or a test standing in
/// for one — supplies <see cref="IdleVaultEventStream"/>, which simply never delivers. That keeps
/// every caller on one shape, because the correct behaviour without a socket is the behaviour
/// with a silent one: synchronise on the timer. See ADR 0012.
/// </remarks>
IVaultEventStream Events { get; }
/// <summary>
/// Asks the identity provider, now, whether this sign-in still stands — at most once a minute.
/// </summary>
/// <remarks>
/// For what must not go on working for a person the account has lost: connecting through Spelunk asks
/// before every tunnel, so a removed or disabled account stops it within a minute rather than when an
/// access token happens to expire. A stand-in server answers yes, which is why this has a body.
/// </remarks>
/// <exception cref="DodoSSH.Client.Auth.OidcException">
/// The provider refused (<c>invalid_grant</c>), or could not be asked.
/// </exception>
Task VerifySignInAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <summary>
/// Reaches a host through the server, for a host set to connect that way.
/// </summary>
/// <remarks>
/// Never null, for the reason <see cref="Events"/> is not: a server without a relay supplies
/// <see cref="UnavailableServerRelay"/>, which refuses in words. See ADR 0004.
/// </remarks>
IServerRelay Relay { 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>
/// 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>
/// 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,
TimeProvider clock)
{
ServerUrl = serverUrl;
this.http = http;
Configuration = configuration;
Meta = meta;
Oidc = oidc;
this.tokens = tokens;
Api = api;
// Decided from what this server said it supports rather than attempted and allowed to fail,
// which is the same capability negotiation SyncOptions below does — see ADR 0002. A client
// that dialled anyway would reconnect against a 404 for the whole session, and would look
// from the outside exactly like one whose network was eating WebSockets.
Events = meta.Features.Contains(VaultEvents.Feature, StringComparer.Ordinal)
? new VaultEventStream(serverUrl, tokens, clock)
: IdleVaultEventStream.Instance;
// Decided the same way and for the same reason. Configuration.Relay says the same thing twice;
// meta is what the server says it does, and is what Events is decided from.
Relay = meta.Features.Contains(RelayProtocol.Feature, StringComparer.Ordinal)
? new ServerRelay(api)
: UnavailableServerRelay.Instance;
}
/// <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 Task VerifySignInAsync(CancellationToken cancellationToken) =>
tokens.RefreshNowAsync(TimeSpan.FromMinutes(1), cancellationToken);
/// <inheritdoc />
public ISyncApi Sync => Api;
/// <inheritdoc />
public ITeamApi Teams => Api;
/// <inheritdoc />
public IDirectoryApi Directory => Api;
/// <inheritdoc />
public IVaultGrantApi Grants => Api;
/// <inheritdoc />
public IVaultEventStream Events { get; }
/// <inheritdoc />
public IServerRelay Relay { get; }
/// <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),
};
/// <inheritdoc />
public string? RefreshToken => tokens.RefreshToken;
/// <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,
Func<OidcClientOptions, OidcClientOptions>? configureOidc = null)
{
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);
// The head gets a say in how the authorization response comes back, and in nothing else.
// Everything that decides whether the flow is *safe* — PKCE, the state check, the discovery
// document, the token exchange — is built from the server's own configuration above and is not
// reachable from here. See OidcClientOptions.CallbackFactory.
var oidcOptions = BuildOidcOptions(configuration);
var oidc = new OidcClient(
transport,
browser,
clock,
configureOidc is null ? oidcOptions : configureOidc(oidcOptions));
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),
clock);
}
catch
{
transport.Dispose();
throw;
}
}
/// <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),
clock);
}
catch
{
transport.Dispose();
throw;
}
}
/// <inheritdoc />
public void Dispose()
{
if (disposed)
{
return;
}
disposed = true;
// First, and without waiting: the socket's own loops read the token provider and the transport
// below, so tearing either down while it is still dialling would surface as a fault on a
// background thread at the moment a user signed out.
Events.Dispose();
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,
};
}