using System.Globalization; using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using DodoSSH.Contracts; namespace DodoSSH.Client.Api; /// Supplies the bearer token for API calls, refreshing it when needed. /// /// An abstraction because token lifetime is the auth layer's problem, not the API client's. The /// client asks for a token per request and never caches one, so a refresh that happens mid-session is /// invisible here rather than something every call site has to remember to handle. /// public interface IAccessTokenProvider { /// Returns a currently-valid access token. ValueTask GetAccessTokenAsync(CancellationToken cancellationToken); } /// /// The account calls: who am I, and publish my first key. /// /// /// Separated for the same reason as . What the session layer does with these is /// decide between enrolling and unlocking, and persist the result so the next launch needs no network; /// testing that against a stubbed HTTP transport would prove the right bytes were sent and nothing /// about the decision. /// public interface IAccountApi { /// Reads the caller's profile, unlock material and reachable vaults. Task GetMeAsync(CancellationToken cancellationToken); /// Publishes the caller's first identity key and creates their personal vault. Task EnrollAsync(EnrollmentRequest request, CancellationToken cancellationToken); /// /// Registers a device key against an account that is already enrolled. /// /// /// On the interface rather than only on the client, because the session layer decides when to /// offer this — after an unlock, never before — and that decision is worth testing without HTTP. /// Task RegisterDeviceAsync( RegisterDeviceRequest request, CancellationToken cancellationToken); /// /// Withdraws a device key, so that machine can no longer unlock without the passphrase. /// /// /// Whether the account had that device. False means it did not, which a caller withdrawing its own /// device should treat as having arrived rather than as a failure — another machine may have revoked it /// first, and the goal state is the same either way. /// Task RevokeDeviceAsync(Guid deviceId, CancellationToken cancellationToken); } /// /// Teams, their members, and the vaults they own. /// /// /// Separated from although the two are used together, because they are /// different kinds of act. Everything here changes what the server will serve and can be /// performed by anything holding a token. Issuing a grant needs a vault key, which only an unlocked /// session has — so the two live behind different interfaces and are tested against different fakes. /// public interface ITeamApi { /// Lists the teams the caller belongs to. Task> ListTeamsAsync(CancellationToken cancellationToken); /// Creates a team, with the caller as its owner. Task CreateTeamAsync(CreateTeamRequest request, CancellationToken cancellationToken); /// Renames a team, or changes its description. Task UpdateTeamAsync( Guid teamId, UpdateTeamRequest request, CancellationToken cancellationToken); /// /// Archives a team. Refused while it still owns vaults. /// /// /// Whether there was a team to archive. False means there was not, which a caller driving towards /// "that team is gone" should treat as having arrived. /// Task ArchiveTeamAsync(Guid teamId, CancellationToken cancellationToken); /// Hands ownership to another member, demoting the outgoing owner to admin. Task TransferTeamOwnershipAsync( Guid teamId, TransferTeamOwnershipRequest request, CancellationToken cancellationToken); /// Lists a team's members. Task> ListTeamMembersAsync( Guid teamId, CancellationToken cancellationToken); /// Adds a member to a team. Task AddTeamMemberAsync( Guid teamId, AddTeamMemberRequest request, CancellationToken cancellationToken); /// Changes a member's role. Task ChangeTeamMemberRoleAsync( Guid teamId, Guid userId, ChangeTeamMemberRoleRequest request, CancellationToken cancellationToken); /// /// Removes a member, revoking every vault key grant they hold from this team. /// /// /// Whether the team had that member. False means it did not, which a caller driving towards /// "they are not in this team" should treat as having arrived. /// Task RemoveTeamMemberAsync(Guid teamId, Guid userId, CancellationToken cancellationToken); /// Lists a team's invitations, including the ones already dealt with. Task> ListTeamInvitationsAsync( Guid teamId, CancellationToken cancellationToken); /// Invites an email address to a team. Task CreateTeamInvitationAsync( Guid teamId, CreateTeamInvitationRequest request, CancellationToken cancellationToken); /// /// Withdraws an invitation that has not been taken up. /// /// /// Whether there was a live invitation to withdraw. False covers one that was never there and one /// already claimed — a claimed invitation is a membership now, and removing a member is a different /// operation with different consequences. /// Task RevokeTeamInvitationAsync( Guid teamId, Guid invitationId, CancellationToken cancellationToken); /// Creates a vault owned by a team, with the creator's key grant. Task CreateTeamVaultAsync( Guid teamId, CreateTeamVaultRequest request, CancellationToken cancellationToken); } /// /// The public-key directory and the log that makes it checkable. /// /// /// The two belong together and are used together: a directory answer is a claim, and the key log is /// what turns it into something a client can verify. Splitting them would make it possible to build a /// caller that reads one and not the other, which is precisely the mistake — see ADR 0001 — that /// undoes end-to-end encryption entirely. /// public interface IDirectoryApi { /// Looks a user up by exact email address. There is no search. Task> LookupByEmailAsync( string email, CancellationToken cancellationToken); /// Looks up an account the caller shares a team with. Task LookupByIdAsync(Guid userId, CancellationToken cancellationToken); /// Reads entries after a sequence, with the log's current head. Task ReadKeyLogAsync( long afterSequence, int? limit, CancellationToken cancellationToken); } /// /// Vault key grants: who can open a vault, and the record of who let them. /// /// /// The wrapped key and the signature are produced by an unlocked session and are opaque to everything /// between it and the recipient, this interface included. /// public interface IVaultGrantApi { /// /// Renames a vault. /// /// /// Here rather than on because the subject is a vault, and because the vaults /// screen that calls it is about vaults — the team a vault belongs to is behind it, and renaming one /// is not an operation on the team. The server renames that team with it where it owns nothing else. /// Task RenameVaultAsync( Guid vaultId, UpdateVaultRequest request, CancellationToken cancellationToken); /// /// Deletes a vault and withdraws every key to it. /// /// Whether there was a vault to delete. /// /// /// False rather than an exception for a vault that is already gone, exactly as /// answers about a grant: a caller driving towards "this vault is /// no longer there" has arrived, and two admins deleting the same vault must not leave the slower one /// looking at an error about something that happened. /// /// /// What it does not do is reach anybody's machine. A member who synced before this holds their copy /// afterwards — see ADR 0001 on revocation — and the interface that offers this has to say so. /// /// Task DeleteVaultAsync(Guid vaultId, CancellationToken cancellationToken); /// Lists who holds a key to this vault. Task ListVaultGrantsAsync(Guid vaultId, CancellationToken cancellationToken); /// Records a vault key wrapped to another member. Task IssueVaultGrantAsync( Guid vaultId, IssueVaultGrantRequest request, CancellationToken cancellationToken); /// /// Advances this vault to a fresh key generation, wrapped to the caller. /// /// The vault at its new generation, with the caller's grants for the earlier ones. /// /// The key is generated by the caller and sealed to itself; the server contributes the moment it /// takes effect, which is the one part a client cannot decide on its own. Wrapping the new /// generation to everybody else is a separate act, and it is the caller's — see /// . /// Task RekeyVaultAsync( Guid vaultId, RekeyVaultRequest request, CancellationToken cancellationToken); /// /// Withdraws a member's key to this vault. /// /// Whether there was a live grant to withdraw. /// /// Blocks future reads and nothing else. Whatever they have already pulled is on their machine; /// the remediation for a departure is rotating the SSH credential. See ADR 0001. /// Task RevokeVaultGrantAsync( Guid vaultId, Guid userId, CancellationToken cancellationToken); } /// /// The two vault-synchronisation calls, separated so the sync engine can be driven without HTTP. /// /// /// The sync engine's job is a conflict-resolution policy, and testing a policy against a stubbed /// transport only proves that the right bytes were sent. Behind this interface the suite runs an /// in-memory server that enforces the real version checks, assigns real change sequences and issues /// real cursors — so a test can assert what happens when two clients edit one host, which is the /// question that actually matters. /// public interface ISyncApi { /// Reads vault changes after a cursor. Task SyncPullAsync( Guid vaultId, SyncPullRequest request, CancellationToken cancellationToken); /// Applies a batch of vault changes. Task SyncPushAsync( Guid vaultId, SyncPushRequest request, CancellationToken cancellationToken); } /// /// The typed client for one DodoSSH server. /// /// /// /// Everything goes through DodoSSH.Contracts and its source-generated serialiser, which is the /// actual contract between the two sides — not the OpenAPI document. Requests are written with /// StrictRequestOptions on the server and read here with ResponseOptions, so an older /// client tolerates a newer server's extra fields instead of failing on them. /// /// /// Discovery is unauthenticated by necessity: a client has to learn how to authenticate before it can. /// Everything else carries a bearer token. /// /// public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider tokens) : IAccountApi, ISyncApi, ITeamApi, IDirectoryApi, IVaultGrantApi { private const string MetaPath = "/api/v1/meta"; private const string ConfigurationPath = "/.well-known/dodossh-configuration"; private const string MePath = "/api/v1/me"; private const string EnrollmentPath = "/api/v1/me/enrollment"; private const string DevicesPath = "/api/v1/me/devices"; private const string DirectoryPath = "/api/v1/directory"; private const string KeyLogPath = "/api/v1/keylog"; private const string TeamsPath = "/api/v1/teams"; /// /// Reads the server's capabilities, versions and limits. /// /// /// Unauthenticated, and the replacement for URL-based API versioning: when client and server /// upgrade independently — normal for self-hosted software — a client has to ask what this /// particular server supports rather than assume. See ADR 0002. /// public Task GetMetaAsync(CancellationToken cancellationToken) => GetAnonymousAsync(MetaPath, DodoSshJsonContext.Default.MetaResponse, cancellationToken); /// /// Reads everything needed to begin authenticating. /// /// /// This is the onboarding story: the user types one server URL and the client discovers the OIDC /// authority, the client id, the scopes and the relay from it. /// public Task GetConfigurationAsync(CancellationToken cancellationToken) => GetAnonymousAsync( ConfigurationPath, DodoSshJsonContext.Default.DodoSshConfiguration, cancellationToken); /// /// Reads the caller's profile, unlock material and reachable vaults. /// /// /// The first authenticated call a client makes, and the only one that works before enrollment. It /// also provisions the account, so its UserId is available before enrolling — which matters, /// because the secret bundle's AAD binds to that id and therefore cannot be built any earlier. /// public Task GetMeAsync(CancellationToken cancellationToken) => SendAsync(HttpMethod.Get, MePath, null, DodoSshJsonContext.Default.MeResponse, cancellationToken); /// Publishes the caller's first identity key and creates their personal vault. public Task EnrollAsync( EnrollmentRequest request, CancellationToken cancellationToken) => SendAsync( HttpMethod.Post, EnrollmentPath, JsonContent.Create(request, DodoSshJsonContext.Default.EnrollmentRequest), DodoSshJsonContext.Default.EnrollmentResponse, cancellationToken); /// Registers a device key against an already-enrolled account. /// /// Requires an unlocked vault, because the wrap can only be produced by something holding the secret /// bundle. That is also what proves possession to the server, which is why there is no challenge here. /// public Task RegisterDeviceAsync( RegisterDeviceRequest request, CancellationToken cancellationToken) => SendAsync( HttpMethod.Post, DevicesPath, JsonContent.Create(request, DodoSshJsonContext.Default.RegisterDeviceRequest), DodoSshJsonContext.Default.RegisterDeviceResponse, cancellationToken); /// public Task RevokeDeviceAsync(Guid deviceId, CancellationToken cancellationToken) => DeleteAsync( string.Create(CultureInfo.InvariantCulture, $"{DevicesPath}/{deviceId}"), cancellationToken); /// Reads vault changes after a cursor. /// /// A POST despite being a read: the filters live in the body, cursors are opaque, and no caching is /// wanted. /// public Task SyncPullAsync( Guid vaultId, SyncPullRequest request, CancellationToken cancellationToken) => SendAsync( HttpMethod.Post, $"/api/v1/vaults/{vaultId}/sync/pull", JsonContent.Create(request, DodoSshJsonContext.Default.SyncPullRequest), DodoSshJsonContext.Default.SyncPullResponse, cancellationToken); /// /// Applies a batch of vault changes. /// /// /// Succeeds with per-operation status even when individual operations failed, so one stale item /// cannot block everything else a client queued while offline. Callers must inspect /// SyncPushResult.Status rather than treating a 200 as everything having applied. /// public Task SyncPushAsync( Guid vaultId, SyncPushRequest request, CancellationToken cancellationToken) => SendAsync( HttpMethod.Post, $"/api/v1/vaults/{vaultId}/sync/push", JsonContent.Create(request, DodoSshJsonContext.Default.SyncPushRequest), DodoSshJsonContext.Default.SyncPushResponse, cancellationToken); /// public Task> ListTeamsAsync(CancellationToken cancellationToken) => SendAsync( HttpMethod.Get, TeamsPath, null, DodoSshJsonContext.Default.IReadOnlyListTeamSummary, cancellationToken); /// public Task CreateTeamAsync( CreateTeamRequest request, CancellationToken cancellationToken) => SendAsync( HttpMethod.Post, TeamsPath, JsonContent.Create(request, DodoSshJsonContext.Default.CreateTeamRequest), DodoSshJsonContext.Default.TeamSummary, cancellationToken); /// public Task UpdateTeamAsync( Guid teamId, UpdateTeamRequest request, CancellationToken cancellationToken) => SendAsync( HttpMethod.Put, string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}"), JsonContent.Create(request, DodoSshJsonContext.Default.UpdateTeamRequest), DodoSshJsonContext.Default.TeamSummary, cancellationToken); /// public Task ArchiveTeamAsync(Guid teamId, CancellationToken cancellationToken) => DeleteAsync( string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}"), cancellationToken); /// public Task TransferTeamOwnershipAsync( Guid teamId, TransferTeamOwnershipRequest request, CancellationToken cancellationToken) => SendNoContentAsync( HttpMethod.Post, string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/owner"), JsonContent.Create(request, DodoSshJsonContext.Default.TransferTeamOwnershipRequest), cancellationToken); /// public Task> ListTeamMembersAsync( Guid teamId, CancellationToken cancellationToken) => SendAsync( HttpMethod.Get, string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members"), null, DodoSshJsonContext.Default.IReadOnlyListTeamMemberSummary, cancellationToken); /// public Task AddTeamMemberAsync( Guid teamId, AddTeamMemberRequest request, CancellationToken cancellationToken) => SendAsync( HttpMethod.Post, string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members"), JsonContent.Create(request, DodoSshJsonContext.Default.AddTeamMemberRequest), DodoSshJsonContext.Default.TeamMemberSummary, cancellationToken); /// public Task ChangeTeamMemberRoleAsync( Guid teamId, Guid userId, ChangeTeamMemberRoleRequest request, CancellationToken cancellationToken) => SendAsync( HttpMethod.Put, string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members/{userId}/role"), JsonContent.Create(request, DodoSshJsonContext.Default.ChangeTeamMemberRoleRequest), DodoSshJsonContext.Default.TeamMemberSummary, cancellationToken); /// public Task RemoveTeamMemberAsync( Guid teamId, Guid userId, CancellationToken cancellationToken) => DeleteAsync( string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members/{userId}"), cancellationToken); /// public Task> ListTeamInvitationsAsync( Guid teamId, CancellationToken cancellationToken) => SendAsync( HttpMethod.Get, string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/invitations"), null, DodoSshJsonContext.Default.IReadOnlyListTeamInvitationSummary, cancellationToken); /// public Task CreateTeamInvitationAsync( Guid teamId, CreateTeamInvitationRequest request, CancellationToken cancellationToken) => SendAsync( HttpMethod.Post, string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/invitations"), JsonContent.Create(request, DodoSshJsonContext.Default.CreateTeamInvitationRequest), DodoSshJsonContext.Default.TeamInvitationSummary, cancellationToken); /// public Task RevokeTeamInvitationAsync( Guid teamId, Guid invitationId, CancellationToken cancellationToken) => DeleteAsync( string.Create( CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/invitations/{invitationId}"), cancellationToken); /// public Task CreateTeamVaultAsync( Guid teamId, CreateTeamVaultRequest request, CancellationToken cancellationToken) => SendAsync( HttpMethod.Post, string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/vaults"), JsonContent.Create(request, DodoSshJsonContext.Default.CreateTeamVaultRequest), DodoSshJsonContext.Default.VaultSummary, cancellationToken); /// /// Looks a user up by exact email address. /// /// /// The address is escaped into the query string, which is the one place in this client where a /// value a user typed reaches a URL. rather than string /// concatenation: an unescaped & or # in an address would silently become a /// lookup for something else. /// public Task> LookupByEmailAsync( string email, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(email); return SendAsync( HttpMethod.Get, $"{DirectoryPath}?email={Uri.EscapeDataString(email)}", null, DodoSshJsonContext.Default.IReadOnlyListDirectoryEntry, cancellationToken); } /// public async Task LookupByIdAsync( Guid userId, CancellationToken cancellationToken) { var entries = await SendAsync( HttpMethod.Get, string.Create(CultureInfo.InvariantCulture, $"{DirectoryPath}?userId={userId}"), null, DodoSshJsonContext.Default.IReadOnlyListDirectoryEntry, cancellationToken) .ConfigureAwait(false); return entries.Count == 0 ? null : entries[0]; } /// public Task ReadKeyLogAsync( long afterSequence, int? limit, CancellationToken cancellationToken) { var path = limit is null ? string.Create(CultureInfo.InvariantCulture, $"{KeyLogPath}?after={afterSequence}") : string.Create( CultureInfo.InvariantCulture, $"{KeyLogPath}?after={afterSequence}&limit={limit}"); return SendAsync( HttpMethod.Get, path, null, DodoSshJsonContext.Default.KeyLogPage, cancellationToken); } /// public Task RenameVaultAsync( Guid vaultId, UpdateVaultRequest request, CancellationToken cancellationToken) => SendAsync( HttpMethod.Put, string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}"), JsonContent.Create(request, DodoSshJsonContext.Default.UpdateVaultRequest), DodoSshJsonContext.Default.VaultSummary, cancellationToken); /// public Task DeleteVaultAsync(Guid vaultId, CancellationToken cancellationToken) => DeleteAsync( string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}"), cancellationToken); /// public Task ListVaultGrantsAsync( Guid vaultId, CancellationToken cancellationToken) => SendAsync( HttpMethod.Get, string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}/grants"), null, DodoSshJsonContext.Default.VaultGrantsResponse, cancellationToken); /// public Task IssueVaultGrantAsync( Guid vaultId, IssueVaultGrantRequest request, CancellationToken cancellationToken) => SendNoContentAsync( HttpMethod.Post, string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}/grants"), JsonContent.Create(request, DodoSshJsonContext.Default.IssueVaultGrantRequest), cancellationToken); /// public Task RekeyVaultAsync( Guid vaultId, RekeyVaultRequest request, CancellationToken cancellationToken) => SendAsync( HttpMethod.Post, string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}/rekey"), JsonContent.Create(request, DodoSshJsonContext.Default.RekeyVaultRequest), DodoSshJsonContext.Default.VaultSummary, cancellationToken); /// public Task RevokeVaultGrantAsync( Guid vaultId, Guid userId, CancellationToken cancellationToken) => DeleteAsync( string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}/grants/{userId}"), cancellationToken); private async Task GetAnonymousAsync( string path, System.Text.Json.Serialization.Metadata.JsonTypeInfo typeInfo, CancellationToken cancellationToken) { using var request = new HttpRequestMessage(HttpMethod.Get, path); return await SendCoreAsync(request, typeInfo, cancellationToken).ConfigureAwait(false); } private async Task SendAsync( HttpMethod method, string path, HttpContent? content, System.Text.Json.Serialization.Metadata.JsonTypeInfo typeInfo, CancellationToken cancellationToken) { using var request = new HttpRequestMessage(method, path) { Content = content }; var token = await tokens.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); return await SendCoreAsync(request, typeInfo, cancellationToken).ConfigureAwait(false); } /// /// Sends a request whose success carries no body. /// /// /// Its own path for the reason gives, minus the 404: a grant that will /// not be recorded, or an ownership transfer that will not happen, is a failure with a problem /// document behind it, so there is nothing here to translate into a return value. /// private async Task SendNoContentAsync( HttpMethod method, string path, HttpContent? content, CancellationToken cancellationToken) { using var request = new HttpRequestMessage(method, path) { Content = content }; var token = await tokens.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var body = await response.Content .ReadAsStringAsync(cancellationToken) .ConfigureAwait(false); throw DodoSshApiException.FromResponse(response.StatusCode, body); } } /// /// Sends a delete whose success carries no body. /// /// True for a 2xx, false for a 404; anything else throws. /// /// Its own path rather than with some empty response type, because the two /// disagree about what a missing body means. Everywhere else a 200 with nothing in it is a server bug /// worth an exception; here it is the answer. /// private async Task DeleteAsync(string path, CancellationToken cancellationToken) { using var request = new HttpRequestMessage(HttpMethod.Delete, path); var token = await tokens.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false); if (response.StatusCode == HttpStatusCode.NotFound) { return false; } if (!response.IsSuccessStatusCode) { var body = await response.Content .ReadAsStringAsync(cancellationToken) .ConfigureAwait(false); throw DodoSshApiException.FromResponse(response.StatusCode, body); } return true; } private async Task SendCoreAsync( HttpRequestMessage request, System.Text.Json.Serialization.Metadata.JsonTypeInfo typeInfo, CancellationToken cancellationToken) { using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var body = await response.Content .ReadAsStringAsync(cancellationToken) .ConfigureAwait(false); throw DodoSshApiException.FromResponse(response.StatusCode, body); } var value = await response.Content .ReadFromJsonAsync(typeInfo, cancellationToken) .ConfigureAwait(false); // A 200 with a null body is a server bug, but it must not surface as a NullReferenceException // three frames further up where the cause is invisible. return value ?? throw new DodoSshApiException( HttpStatusCode.OK, null, $"The server returned an empty body where a {typeof(T).Name} was expected."); } }