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;
public async ValueTask 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();
}
///
/// 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; }
/// 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; }
}
///
/// 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)
{
ServerUrl = serverUrl;
this.http = http;
Configuration = configuration;
Meta = meta;
Oidc = oidc;
this.tokens = tokens;
Api = api;
}
/// 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 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),
};
///
/// 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)
{
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;
}
}
///
public void Dispose()
{
if (disposed)
{
return;
}
disposed = true;
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,
};
}