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