Public Access
Add the server client and client-side enrollment
A typed client over DodoSSH.Contracts, and the orchestration that turns a passphrase into an enrolled identity: generate keys, have the identity provider sign over them, wrap the bundle three ways, create the personal vault, publish. Ordering here is forced, not chosen. The secret bundle's AAD binds to the server-assigned user id, so /me has to be read before anything can be wrapped -- which is exactly why /me provisions the account and returns its id even while reporting that enrollment is required. That constraint was designed into the server earlier; this is the first code that depends on it. The grant tuple now has a real canonical encoding (crypto.md 7.3) rather than the placeholder signature I would otherwise have had to invent and then keep. §7 named the tuple without specifying how to encode it; this fills that in with the same conventions as 7.1, and the self-grant at enrollment is already in its final format. The signature covers SHA-256(wrappedKey) rather than the key, so a verifier can check attribution without holding the vault key at all. The most valuable tests are the negative ones about the request body: the server is meant to be unable to read what it stores, and a refactor that put a passphrase or a private key into the enrollment request would be invisible to every other test in the repository. So one asserts the body contains neither the passphrase, the recovery code, nor any private key in base64 or hex. Another opens the same bundle three ways -- passphrase, recovery code and device key -- which is what makes a passphrase change a one-row update. ClientEnrollment depends on IKeyBindingAuthorizer rather than the whole OidcClient. It needs exactly one capability, and depending on the full client would drag discovery and token exchange into every test of key binding. Two things fixed while building it. The recovery code buffer was sized one separator short, so every enrollment threw IndexOutOfRange -- caught immediately because nine of ten tests failed identically. And the crypto enum collided with Domain.GrantKind in the server, so it is GrantPurpose there; the numeric values still have to match, which the doc and a test both say. 448 tests pass, zero warnings on a clean rebuild, format clean.
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
using System.Security.Cryptography;
|
||||
using DodoSSH.Client.Auth;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Crypto;
|
||||
using NSec.Cryptography;
|
||||
|
||||
namespace DodoSSH.Client.Api;
|
||||
|
||||
/// <summary>What enrolling produced, for the caller to hold and persist.</summary>
|
||||
/// <remarks>
|
||||
/// The bundle and the vault key are live secrets. The caller owns their lifetime and must dispose the
|
||||
/// bundle; neither is ever written anywhere but the OS keystore and the encrypted local cache.
|
||||
/// </remarks>
|
||||
/// <param name="Response">The server's answer, including the vault and key log position.</param>
|
||||
/// <param name="Bundle">The identity key pair, unlocked for this session.</param>
|
||||
/// <param name="PersonalVaultKey">The personal vault's key, in plaintext for this session.</param>
|
||||
/// <param name="DevicePrivateKey">
|
||||
/// The enrolled device's X25519 private key. Belongs in the OS keystore — it is what lets a later
|
||||
/// launch unlock without the passphrase.
|
||||
/// </param>
|
||||
/// <param name="RecoveryCode">
|
||||
/// The generated recovery code, which must be shown to the user once and never stored. Losing this
|
||||
/// along with the passphrase and every device means the vault is unrecoverable, and no server-side
|
||||
/// reset is possible by design.
|
||||
/// </param>
|
||||
public sealed record EnrollmentOutcome(
|
||||
EnrollmentResponse Response,
|
||||
UserSecretBundle Bundle,
|
||||
byte[] PersonalVaultKey,
|
||||
byte[] DevicePrivateKey,
|
||||
string RecoveryCode);
|
||||
|
||||
/// <summary>
|
||||
/// Runs enrollment: generate keys, have the identity provider sign over them, and publish.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Ordering here is not a matter of taste. The secret bundle's AAD binds to the server-assigned user
|
||||
/// id, so <c>/me</c> must be read before anything can be wrapped — which is why <c>/me</c> provisions
|
||||
/// the account and returns its id even when it reports that enrollment is required.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Everything the server receives is opaque to it. It gets public keys, wrapped blobs it cannot open,
|
||||
/// and signatures it does not verify beyond the statement's own. That is the whole point: the server
|
||||
/// stores the vault and cannot read it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ClientEnrollment(
|
||||
DodoSshApiClient api,
|
||||
IKeyBindingAuthorizer keyBinding,
|
||||
TimeProvider clock)
|
||||
{
|
||||
/// <summary>Bytes of entropy behind a recovery code.</summary>
|
||||
private const int RecoveryEntropyBytes = 20;
|
||||
|
||||
/// <summary>
|
||||
/// Enrolls the caller.
|
||||
/// </summary>
|
||||
/// <param name="me">
|
||||
/// The result of <see cref="DodoSshApiClient.GetMeAsync"/>, which supplies the user id the wraps
|
||||
/// bind to.
|
||||
/// </param>
|
||||
/// <param name="passphrase">The vault passphrase. Never transmitted or stored.</param>
|
||||
/// <param name="deviceName">Human-readable name for this machine.</param>
|
||||
/// <param name="vaultName">Display name for the personal vault. Plaintext, as vault names are.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task<EnrollmentOutcome> EnrollAsync(
|
||||
MeResponse me,
|
||||
string passphrase,
|
||||
string deviceName,
|
||||
string vaultName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(me);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(passphrase);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(deviceName);
|
||||
|
||||
var now = clock.GetUtcNow();
|
||||
var bundle = UserSecretBundle.Create(now);
|
||||
|
||||
try
|
||||
{
|
||||
var statement = BuildStatement(me, bundle, now, deviceName);
|
||||
|
||||
// The provider signs over the statement's hash, which is what stops the DodoSSH server
|
||||
// fabricating a key for a user who never enrolled. See ADR 0001.
|
||||
var idToken = await keyBinding
|
||||
.AuthorizeKeyBindingAsync(
|
||||
KeyStatementCodec.ComputeNonce(ToFields(statement)),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var request = BuildRequest(
|
||||
me, bundle, statement, idToken, passphrase, vaultName, now, out var material);
|
||||
|
||||
var response = await api.EnrollAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new EnrollmentOutcome(
|
||||
response,
|
||||
bundle,
|
||||
material.VaultKey,
|
||||
material.DevicePrivateKey,
|
||||
material.RecoveryCode);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// The caller never receives the bundle on failure, so this is the only place that can
|
||||
// release its guarded memory.
|
||||
bundle.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static KeyStatement BuildStatement(
|
||||
MeResponse me,
|
||||
UserSecretBundle bundle,
|
||||
DateTimeOffset now,
|
||||
string deviceName) =>
|
||||
new(
|
||||
Version: KeyStatementCodec.CurrentVersion,
|
||||
Issuer: me.Issuer,
|
||||
Subject: me.Subject,
|
||||
Email: me.Email,
|
||||
EncryptionPublicKey: bundle.EncryptionPublicKey,
|
||||
SigningPublicKey: bundle.SigningPublicKey,
|
||||
KeyGeneration: 1,
|
||||
CreatedAt: now,
|
||||
DeviceName: deviceName);
|
||||
|
||||
/// <summary>Secrets the caller keeps after a successful enrollment.</summary>
|
||||
private readonly record struct SessionMaterial(
|
||||
byte[] VaultKey,
|
||||
byte[] DevicePrivateKey,
|
||||
string RecoveryCode);
|
||||
|
||||
/// <remarks>
|
||||
/// The passphrase is a parameter rather than a field, so it lives only for the duration of this call
|
||||
/// and never becomes state on a long-lived object that a heap dump would find.
|
||||
/// </remarks>
|
||||
private static EnrollmentRequest BuildRequest(
|
||||
MeResponse me,
|
||||
UserSecretBundle bundle,
|
||||
KeyStatement statement,
|
||||
string idToken,
|
||||
string passphrase,
|
||||
string vaultName,
|
||||
DateTimeOffset now,
|
||||
out SessionMaterial material)
|
||||
{
|
||||
var descriptor = DshAad.UserSecretBundle(me.UserId);
|
||||
|
||||
// A fresh salt per wrap, and the parameters travel with it — so raising the cost later is a
|
||||
// per-user migration at next unlock rather than a breaking change.
|
||||
var passphraseSalt = RandomNumberGenerator.GetBytes(CryptoSpec.SaltSize);
|
||||
var recoverySalt = RandomNumberGenerator.GetBytes(CryptoSpec.SaltSize);
|
||||
var recoveryCode = GenerateRecoveryCode();
|
||||
|
||||
byte[] passphraseWrap;
|
||||
byte[] recoveryWrap;
|
||||
|
||||
using (var master = MasterKey.Derive(
|
||||
passphrase, passphraseSalt, Argon2Profile.PassphraseDefault))
|
||||
{
|
||||
passphraseWrap = master.WrapBundle(bundle, descriptor);
|
||||
}
|
||||
|
||||
// The recovery code carries real entropy, so it needs far less stretching than a passphrase.
|
||||
using (var recoveryMaster = MasterKey.Derive(
|
||||
recoveryCode, recoverySalt, Argon2Profile.RandomSecret))
|
||||
{
|
||||
recoveryWrap = recoveryMaster.WrapBundle(bundle, descriptor);
|
||||
}
|
||||
|
||||
using var deviceKey = Key.Create(
|
||||
KeyAgreementAlgorithm.X25519,
|
||||
new KeyCreationParameters { ExportPolicy = KeyExportPolicies.AllowPlaintextExport });
|
||||
|
||||
var devicePublicKey = deviceKey.PublicKey.Export(KeyBlobFormat.RawPublicKey);
|
||||
var deviceWrap = bundle.SealTo(devicePublicKey, descriptor);
|
||||
|
||||
var vault = BuildPersonalVault(me, bundle, vaultName, now, out var vaultKey);
|
||||
|
||||
material = new SessionMaterial(
|
||||
vaultKey,
|
||||
deviceKey.Export(KeyBlobFormat.RawPrivateKey),
|
||||
recoveryCode);
|
||||
|
||||
return new EnrollmentRequest(
|
||||
Statement: statement,
|
||||
StatementSignature: DshSignatures.SignKeyStatement(
|
||||
bundle.SigningKey,
|
||||
KeyStatementCodec.Encode(ToFields(statement))),
|
||||
IdentityProviderToken: idToken,
|
||||
WrappedPrivateKey: passphraseWrap,
|
||||
KdfParameters: ToContract(passphraseSalt, Argon2Profile.PassphraseDefault),
|
||||
DevicePublicKey: devicePublicKey,
|
||||
DeviceWrappedPrivateKey: deviceWrap,
|
||||
RecoveryWrappedPrivateKey: recoveryWrap,
|
||||
RecoveryKdfParameters: ToContract(recoverySalt, Argon2Profile.RandomSecret),
|
||||
PersonalVault: vault);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The vault id is chosen here rather than by the server, which is what makes enrollment safely
|
||||
/// retryable and is required by the grant signature — the signed tuple covers the vault id.
|
||||
/// <para>
|
||||
/// A self-grant carries no key log head: there is no third party whose key could have been
|
||||
/// substituted, and the log entry that would supply one is written by the server in the same
|
||||
/// transaction, so it cannot be signed over here.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static PersonalVaultRequest BuildPersonalVault(
|
||||
MeResponse me,
|
||||
UserSecretBundle bundle,
|
||||
string vaultName,
|
||||
DateTimeOffset now,
|
||||
out byte[] vaultKey)
|
||||
{
|
||||
var vaultId = Guid.CreateVersion7();
|
||||
vaultKey = VaultKeys.Create();
|
||||
|
||||
var wrappedVaultKey = VaultKeys.WrapTo(vaultKey, bundle.EncryptionPublicKey, vaultId, 1);
|
||||
|
||||
var fingerprint = DshCrypto.ComputeFingerprint(
|
||||
bundle.EncryptionPublicKey,
|
||||
bundle.SigningPublicKey);
|
||||
|
||||
var grant = GrantStatementCodec.Encode(
|
||||
vaultId,
|
||||
keyGeneration: 1,
|
||||
GrantPurpose.Member,
|
||||
granteeUserId: me.UserId,
|
||||
granteeKeyFingerprint: fingerprint,
|
||||
wrappedKey: wrappedVaultKey,
|
||||
granterUserId: me.UserId,
|
||||
granterKeyFingerprint: fingerprint,
|
||||
keyLogHead: default,
|
||||
grantedAt: now);
|
||||
|
||||
return new PersonalVaultRequest(
|
||||
VaultId: vaultId,
|
||||
Name: vaultName,
|
||||
WrappedVaultKey: wrappedVaultKey,
|
||||
GrantSignature: GrantStatementCodec.Sign(bundle.SigningKey, grant),
|
||||
GrantedAt: now);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a printable recovery code.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Base32 over Crockford's alphabet, which omits I, L, O and U — so a code read aloud or copied off
|
||||
/// a screen cannot be mistranscribed into a different valid code, and cannot spell anything
|
||||
/// unfortunate. Grouped for legibility, and the groups are not part of the secret: the derivation
|
||||
/// uses the string exactly as shown, dashes included, because that is what the user will type back.
|
||||
/// </remarks>
|
||||
private static string GenerateRecoveryCode()
|
||||
{
|
||||
const string Alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
||||
const int BitsPerCharacter = 5;
|
||||
const int CharactersPerGroup = 5;
|
||||
|
||||
// 20 bytes is 160 bits, which divides evenly into 32 five-bit characters — so no bits are
|
||||
// discarded and no padding is needed. Separators go between groups, hence one fewer than the
|
||||
// number of groups.
|
||||
var totalCharacters = RecoveryEntropyBytes * 8 / BitsPerCharacter;
|
||||
var separators = (totalCharacters - 1) / CharactersPerGroup;
|
||||
|
||||
var entropy = RandomNumberGenerator.GetBytes(RecoveryEntropyBytes);
|
||||
var characters = new char[totalCharacters + separators];
|
||||
|
||||
var index = 0;
|
||||
|
||||
for (var position = 0; position < totalCharacters; position++)
|
||||
{
|
||||
if (position > 0 && position % CharactersPerGroup == 0)
|
||||
{
|
||||
characters[index++] = '-';
|
||||
}
|
||||
|
||||
var value = 0;
|
||||
for (var offset = 0; offset < BitsPerCharacter; offset++)
|
||||
{
|
||||
var bit = (position * BitsPerCharacter) + offset;
|
||||
value = (value << 1) | ((entropy[bit / 8] >> (7 - (bit % 8))) & 1);
|
||||
}
|
||||
|
||||
characters[index++] = Alphabet[value];
|
||||
}
|
||||
|
||||
CryptographicOperations.ZeroMemory(entropy);
|
||||
|
||||
return new string(characters);
|
||||
}
|
||||
|
||||
private static KdfParameters ToContract(byte[] salt, Argon2Profile profile) =>
|
||||
new(
|
||||
Algorithm: "argon2id",
|
||||
Salt: salt,
|
||||
MemoryKibibytes: profile.MemoryKibibytes,
|
||||
Passes: profile.Passes,
|
||||
Parallelism: Argon2Profile.Parallelism);
|
||||
|
||||
private static KeyStatementFields ToFields(KeyStatement statement) =>
|
||||
new(
|
||||
statement.Version,
|
||||
statement.Issuer,
|
||||
statement.Subject,
|
||||
statement.Email,
|
||||
statement.EncryptionPublicKey,
|
||||
statement.SigningPublicKey,
|
||||
statement.KeyGeneration,
|
||||
statement.CreatedAt,
|
||||
statement.DeviceName);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
The typed client for a DodoSSH server. Avalonia-free, so the whole request/response surface
|
||||
and the enrollment orchestration are testable against a stubbed server.
|
||||
-->
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../DodoSSH.Contracts/DodoSSH.Contracts.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Crypto/DodoSSH.Crypto.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Auth/DodoSSH.Client.Auth.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="DodoSSH.Client.Api.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,175 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using DodoSSH.Contracts;
|
||||
|
||||
namespace DodoSSH.Client.Api;
|
||||
|
||||
/// <summary>Supplies the bearer token for API calls, refreshing it when needed.</summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public interface IAccessTokenProvider
|
||||
{
|
||||
/// <summary>Returns a currently-valid access token.</summary>
|
||||
ValueTask<string> GetAccessTokenAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The typed client for one DodoSSH server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Everything goes through <c>DodoSSH.Contracts</c> and its source-generated serialiser, which is the
|
||||
/// actual contract between the two sides — not the OpenAPI document. Requests are written with
|
||||
/// <c>StrictRequestOptions</c> on the server and read here with <c>ResponseOptions</c>, so an older
|
||||
/// client tolerates a newer server's extra fields instead of failing on them.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Discovery is unauthenticated by necessity: a client has to learn how to authenticate before it can.
|
||||
/// Everything else carries a bearer token.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider tokens)
|
||||
{
|
||||
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";
|
||||
|
||||
/// <summary>
|
||||
/// Reads the server's capabilities, versions and limits.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public Task<MetaResponse> GetMetaAsync(CancellationToken cancellationToken) =>
|
||||
GetAnonymousAsync(MetaPath, DodoSshJsonContext.Default.MetaResponse, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Reads everything needed to begin authenticating.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public Task<DodoSshConfiguration> GetConfigurationAsync(CancellationToken cancellationToken) =>
|
||||
GetAnonymousAsync(
|
||||
ConfigurationPath,
|
||||
DodoSshJsonContext.Default.DodoSshConfiguration,
|
||||
cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Reads the caller's profile, unlock material and reachable vaults.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The first authenticated call a client makes, and the only one that works before enrollment. It
|
||||
/// also provisions the account, so its <c>UserId</c> is available before enrolling — which matters,
|
||||
/// because the secret bundle's AAD binds to that id and therefore cannot be built any earlier.
|
||||
/// </remarks>
|
||||
public Task<MeResponse> GetMeAsync(CancellationToken cancellationToken) =>
|
||||
SendAsync(HttpMethod.Get, MePath, null, DodoSshJsonContext.Default.MeResponse, cancellationToken);
|
||||
|
||||
/// <summary>Publishes the caller's first identity key and creates their personal vault.</summary>
|
||||
public Task<EnrollmentResponse> EnrollAsync(
|
||||
EnrollmentRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
SendAsync(
|
||||
HttpMethod.Post,
|
||||
EnrollmentPath,
|
||||
JsonContent.Create(request, DodoSshJsonContext.Default.EnrollmentRequest),
|
||||
DodoSshJsonContext.Default.EnrollmentResponse,
|
||||
cancellationToken);
|
||||
|
||||
/// <summary>Reads vault changes after a cursor.</summary>
|
||||
/// <remarks>
|
||||
/// A POST despite being a read: the filters live in the body, cursors are opaque, and no caching is
|
||||
/// wanted.
|
||||
/// </remarks>
|
||||
public Task<SyncPullResponse> 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);
|
||||
|
||||
/// <summary>
|
||||
/// Applies a batch of vault changes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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
|
||||
/// <c>SyncPushResult.Status</c> rather than treating a 200 as everything having applied.
|
||||
/// </remarks>
|
||||
public Task<SyncPushResponse> 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);
|
||||
|
||||
private async Task<T> GetAnonymousAsync<T>(
|
||||
string path,
|
||||
System.Text.Json.Serialization.Metadata.JsonTypeInfo<T> typeInfo,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, path);
|
||||
return await SendCoreAsync(request, typeInfo, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<T> SendAsync<T>(
|
||||
HttpMethod method,
|
||||
string path,
|
||||
HttpContent? content,
|
||||
System.Text.Json.Serialization.Metadata.JsonTypeInfo<T> 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);
|
||||
}
|
||||
|
||||
private async Task<T> SendCoreAsync<T>(
|
||||
HttpRequestMessage request,
|
||||
System.Text.Json.Serialization.Metadata.JsonTypeInfo<T> 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.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DodoSSH.Client.Api;
|
||||
|
||||
/// <summary>
|
||||
/// A DodoSSH server returned an error.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Carries the RFC 9457 <c>code</c> extension rather than only the status and prose. The codes are
|
||||
/// constants in <c>DodoSSH.Contracts.ProblemCodes</c>, so the client can branch on
|
||||
/// <see cref="Code"/> — <c>enrollment-required</c> means run enrollment, <c>vault-conflict</c> means
|
||||
/// merge and retry — instead of matching on a message that is free to change.
|
||||
/// </remarks>
|
||||
public sealed class DodoSshApiException(HttpStatusCode statusCode, string? code, string message)
|
||||
: Exception(message)
|
||||
{
|
||||
/// <summary>The HTTP status.</summary>
|
||||
public HttpStatusCode StatusCode { get; } = statusCode;
|
||||
|
||||
/// <summary>The stable machine-readable code, when the server supplied one.</summary>
|
||||
public string? Code { get; } = code;
|
||||
|
||||
/// <summary>
|
||||
/// Builds an exception from a response body.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Tolerates a body that is not ProblemDetails, or not JSON at all. A reverse proxy returning its
|
||||
/// own HTML 502 is a normal thing to meet, and losing the status code while trying to parse it
|
||||
/// would replace a diagnosable failure with a parse error.
|
||||
/// </remarks>
|
||||
internal static DodoSshApiException FromResponse(HttpStatusCode statusCode, string body)
|
||||
{
|
||||
string? code = null;
|
||||
var detail = body;
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(body);
|
||||
var root = document.RootElement;
|
||||
|
||||
if (root.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
code = ReadString(root, "code");
|
||||
detail = ReadString(root, "detail") ?? ReadString(root, "title") ?? body;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Not JSON. The status code still tells the caller what happened.
|
||||
}
|
||||
|
||||
var summary = string.IsNullOrWhiteSpace(detail)
|
||||
? $"The server returned {(int)statusCode}."
|
||||
: $"The server returned {(int)statusCode}: {detail}";
|
||||
|
||||
return new DodoSshApiException(statusCode, code, summary);
|
||||
}
|
||||
|
||||
private static string? ReadString(JsonElement root, string name) =>
|
||||
root.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.String
|
||||
? property.GetString()
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Meziantou.Analyzer": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.0.134, )",
|
||||
"resolved": "3.0.134",
|
||||
"contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ=="
|
||||
},
|
||||
"Microsoft.CodeAnalysis.BannedApiAnalyzers": {
|
||||
"type": "Direct",
|
||||
"requested": "[5.6.0, )",
|
||||
"resolved": "5.6.0",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"dodossh.client.auth": {
|
||||
"type": "Project"
|
||||
},
|
||||
"dodossh.contracts": {
|
||||
"type": "Project"
|
||||
},
|
||||
"dodossh.crypto": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )"
|
||||
}
|
||||
},
|
||||
"libsodium": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[1.0.22, )",
|
||||
"resolved": "1.0.22",
|
||||
"contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
|
||||
},
|
||||
"NSec.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[26.4.0, )",
|
||||
"resolved": "26.4.0",
|
||||
"contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==",
|
||||
"dependencies": {
|
||||
"libsodium": "[1.0.22, 1.0.23)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user