using DodoSSH.Client.Api;
using DodoSSH.Client.Auth;
using DodoSSH.Client.Sync;
using DodoSSH.Contracts;
namespace DodoSSH.Client.Session;
///
/// Stands in for a token provider before anyone has signed in.
///
///
/// 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.
///
internal sealed class UnavailableAccessTokenProvider : IAccessTokenProvider
{
internal static UnavailableAccessTokenProvider Instance { get; } = new();
public ValueTask 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.");
}
///
/// Keeps the bearer token fresh for the life of a connection.
///
///
/// 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.
///
internal sealed class RefreshingAccessTokenProvider(
OidcClient oidc,
TokenSet initial,
TimeProvider clock) : IAccessTokenProvider, IDisposable
{
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)
{
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);
return refreshed.AccessToken;
}
finally
{
gate.Release();
}
}
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.
///
///
/// 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.
///
public interface IVaultServer : IDisposable
{
/// The server this is connected to.
Uri ServerUrl { get; }
/// Who am I, and publish my first key.
IAccountApi Account { get; }
/// Pull and push.
ISyncApi Sync { get; }
/// Teams, their members, and the vaults they own.
ITeamApi Teams { get; }
///
/// The public-key directory, and the key log that makes an answer from it checkable.
///
///
/// 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 KeyLogAudit.
///
IDirectoryApi Directory { get; }
/// Vault key grants: who can open a vault, and who let them.
IVaultGrantApi Grants { get; }
///
/// Notices that something changed, so a synchronisation need not wait for the timer.
///
///
/// Always present, never null: a server that does not offer the feature — or a test standing in
/// for one — supplies , 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.
///
IVaultEventStream Events { get; }
/// Obtains the identity provider's signature over a key statement.
IKeyBindingAuthorizer KeyBinding { get; }
/// 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; }
}
///
/// A signed-in connection to one DodoSSH server.
///
///
///
/// The onboarding story in one object: the user types a server URL, the client reads
/// /.well-known/dodossh-configuration 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.
///
///
/// A session outlives this. Losing the network invalidates the connection, not the vault — which is why
/// syncing takes an per call rather than the session holding one.
///
///
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;
}
/// The server this is connected to.
public Uri ServerUrl { get; }
/// What the server told us about itself and its identity provider.
public DodoSshConfiguration Configuration { get; }
/// Versions, features and limits.
public MetaResponse Meta { get; }
/// The identity provider client, which is also the key-binding authorizer.
public OidcClient Oidc { get; }
/// The authenticated API client.
public DodoSshApiClient Api { get; }
///
public IAccountApi Account => Api;
///
public ISyncApi Sync => Api;
///
public ITeamApi Teams => Api;
///
public IDirectoryApi Directory => Api;
///
public IVaultGrantApi Grants => Api;
///
public IVaultEventStream Events { get; }
///
public IKeyBindingAuthorizer KeyBinding => Oidc;
///
/// Sync tuning derived from what this server actually accepts.
///
///
/// 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.
///
///
public SyncOptions SyncOptions => new()
{
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.
///
/// The DodoSSH server's base URL — the only thing the user has to know.
/// Opens the system browser. Never an embedded one; see RFC 8252.
/// Time source, for token expiry.
/// Cancels the wait for the browser.
public static async Task SignInAsync(
Uri serverUrl,
IBrowserLauncher browser,
TimeProvider clock,
CancellationToken cancellationToken,
Func? 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;
}
}
///
/// 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),
clock);
}
catch
{
transport.Dispose();
throw;
}
}
///
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();
}
///
/// 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.
///
private static OidcClientOptions BuildOidcOptions(DodoSshConfiguration configuration) =>
new()
{
Authority = configuration.Oidc.Authority,
ClientId = configuration.Oidc.ClientId,
Scopes = configuration.Oidc.Scopes,
RequireHttpsMetadata = !configuration.Oidc.Authority.IsLoopback,
};
}