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, }; }