From d3a2794293877497c29405e28146869a72eb2f5e Mon Sep 17 00:00:00 2001 From: Stevan Freeborn Date: Fri, 4 Sep 2026 11:57:09 -0500 Subject: [PATCH] feat: implement default retrievers --- .../{ => Identity}/AtprotoId.cs | 22 ++- .../Identity/DidDocument.cs | 97 ++++++++++ .../Retrieval/DefaultDidDocumentRetriever.cs | 66 +++++++ .../Retrieval/DefaultDidRetriever.cs | 133 ++++++++++++++ .../Retrieval/IDidDocumentRetriever.cs | 19 ++ .../Retrieval/IDidRetriever.cs | 17 ++ .../Unit/DefaultDidDocumentRetriever.cs | 26 +++ .../Unit/DefaultDidRetrieverTests.cs | 172 ++++++++++++++++++ 8 files changed, 544 insertions(+), 8 deletions(-) rename src/StevanFreeborn.Atproto.IdentityResolver/{ => Identity}/AtprotoId.cs (77%) create mode 100644 src/StevanFreeborn.Atproto.IdentityResolver/Identity/DidDocument.cs create mode 100644 src/StevanFreeborn.Atproto.IdentityResolver/Retrieval/DefaultDidDocumentRetriever.cs create mode 100644 src/StevanFreeborn.Atproto.IdentityResolver/Retrieval/DefaultDidRetriever.cs create mode 100644 src/StevanFreeborn.Atproto.IdentityResolver/Retrieval/IDidDocumentRetriever.cs create mode 100644 src/StevanFreeborn.Atproto.IdentityResolver/Retrieval/IDidRetriever.cs create mode 100644 tests/StevanFreeborn.Atproto.IdentityResolver.Tests/Unit/DefaultDidDocumentRetriever.cs create mode 100644 tests/StevanFreeborn.Atproto.IdentityResolver.Tests/Unit/DefaultDidRetrieverTests.cs diff --git a/src/StevanFreeborn.Atproto.IdentityResolver/AtprotoId.cs b/src/StevanFreeborn.Atproto.IdentityResolver/Identity/AtprotoId.cs similarity index 77% rename from src/StevanFreeborn.Atproto.IdentityResolver/AtprotoId.cs rename to src/StevanFreeborn.Atproto.IdentityResolver/Identity/AtprotoId.cs index a7dd472..d7e64ce 100644 --- a/src/StevanFreeborn.Atproto.IdentityResolver/AtprotoId.cs +++ b/src/StevanFreeborn.Atproto.IdentityResolver/Identity/AtprotoId.cs @@ -1,17 +1,17 @@ namespace StevanFreeborn.Atproto.IdentityResolver; /// -/// +/// Represents an identifier for the atproto network. /// public abstract class AtprotoId { /// - /// + /// Gets the string representation of the underlying id. /// public string Value { get; protected set; } = string.Empty; /// - /// + /// Constructs a strongly typed representation of the given id. /// public static AtprotoId From(string atprotoId) { @@ -37,14 +37,14 @@ public abstract class AtprotoId } /// -/// +/// Represents a did on the atproto network. /// public class Did : AtprotoId { private static readonly string[] ValidPrefixes = ["did:web:", "did:plc:"]; /// - /// + /// Gets the method associated with the did. /// public string Method { get; private set; } @@ -72,7 +72,7 @@ public class Did : AtprotoId } /// - /// + /// Constructs a strongly-typed representation of the did. /// public static new Did From(string did) { @@ -103,7 +103,7 @@ public class Did : AtprotoId } /// -/// +/// Represents a handle for an id on the atproto network. /// public class Handle : AtprotoId { @@ -123,8 +123,14 @@ public class Handle : AtprotoId } /// - /// + /// Constructs a strongly-typed representation of the handle. /// + /// The handle for the id you'd like to represent + /// An instance of a + /// + /// Thrown when is null or empty. + /// Thrown when handle starts with '@'. + /// public static new Handle From(string handle) { return new(handle); diff --git a/src/StevanFreeborn.Atproto.IdentityResolver/Identity/DidDocument.cs b/src/StevanFreeborn.Atproto.IdentityResolver/Identity/DidDocument.cs new file mode 100644 index 0000000..eaa1402 --- /dev/null +++ b/src/StevanFreeborn.Atproto.IdentityResolver/Identity/DidDocument.cs @@ -0,0 +1,97 @@ +using System.Collections.ObjectModel; +using System.Text.Json.Serialization; + +using StevanFreeborn.Atproto.IdentityResolver.Json; + +namespace StevanFreeborn.Atproto.IdentityResolver.Identity; + +/// +/// +/// +public sealed class DidDocument +{ + /// + /// + /// + [JsonPropertyName("@context")] + [JsonConverter(typeof(StringToCollectionConverter))] + public Collection Context { get; private set; } = []; + + /// + /// + /// + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + /// + /// + /// + [JsonPropertyName("alsoKnownAs")] + public Collection AlsoKnownAs { get; private set; } = []; + + /// + /// + /// + [JsonPropertyName("verificationMethod")] + public Collection VerificatonMethod { get; private set; } = []; + + /// + /// + /// + [JsonPropertyName("service")] + public Collection Service { get; private set; } = []; +} + +/// +/// +/// +public class VerificationMethod +{ + /// + /// + /// + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + /// + /// + /// + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + /// + /// + /// + [JsonPropertyName("controller")] + public string Controller { get; set; } = string.Empty; + + /// + /// + /// + [JsonPropertyName("publicKeyMultiBase")] + public string PublicKeyMultiBase { get; set; } = string.Empty; +} + +/// +/// +/// +public class Service +{ + /// + /// + /// + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + /// + /// + /// + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + /// + /// + /// + [JsonPropertyName("serviceEndpoint")] + public string ServiceEndpoint { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/StevanFreeborn.Atproto.IdentityResolver/Retrieval/DefaultDidDocumentRetriever.cs b/src/StevanFreeborn.Atproto.IdentityResolver/Retrieval/DefaultDidDocumentRetriever.cs new file mode 100644 index 0000000..7ff3d18 --- /dev/null +++ b/src/StevanFreeborn.Atproto.IdentityResolver/Retrieval/DefaultDidDocumentRetriever.cs @@ -0,0 +1,66 @@ +using System.Text.Json; + +using StevanFreeborn.Atproto.IdentityResolver.Exceptions; +using StevanFreeborn.Atproto.IdentityResolver.Identity; +using StevanFreeborn.Atproto.IdentityResolver.Json; + +namespace StevanFreeborn.Atproto.IdentityResolver.Retrieval; + +/// +/// +/// +public class DefaultDidDocumentRetriever(HttpClient httpClient) : IDidDocumentRetriever +{ + /// + /// + /// + public Task GetByPlcOrThrowAsync(Did did, CancellationToken ct) + { + if (did is null) + { + throw new ArgumentNullException(nameof(did)); + } + + var requestUri = new Uri($"https://plc.directory/{did.Value}", UriKind.Absolute); + return GetOrThrowAsync(requestUri, ct); + } + + /// + /// + /// + public Task GetByWellKnownOrThrowAsync(Handle handle, CancellationToken ct) + { + if (handle is null) + { + throw new ArgumentNullException(nameof(handle)); + } + + var requestUri = new Uri($"https://{handle.Value}/.well-known/did.json", UriKind.Absolute); + return GetOrThrowAsync(requestUri, ct); + } + + private async Task GetOrThrowAsync(Uri requestUri, CancellationToken ct) + { + try + { + using var response = await httpClient.GetAsync(requestUri, ct).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + using var body = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + + var didDocument = await JsonSerializer + .DeserializeAsync(body, JsonContext.Default.DidDocument, ct) + .ConfigureAwait(false); + + return didDocument ?? throw new InvalidOperationException("Deserializing response body resulted in null value."); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + throw new DidDocumentNotFoundException("Unable to retrieve did document. See inner exception", ex); + } + } +} \ No newline at end of file diff --git a/src/StevanFreeborn.Atproto.IdentityResolver/Retrieval/DefaultDidRetriever.cs b/src/StevanFreeborn.Atproto.IdentityResolver/Retrieval/DefaultDidRetriever.cs new file mode 100644 index 0000000..dc5338a --- /dev/null +++ b/src/StevanFreeborn.Atproto.IdentityResolver/Retrieval/DefaultDidRetriever.cs @@ -0,0 +1,133 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using System.Text.Json.Serialization; + +using StevanFreeborn.Atproto.IdentityResolver.Exceptions; +using StevanFreeborn.Atproto.IdentityResolver.Json; + +namespace StevanFreeborn.Atproto.IdentityResolver.Retrieval; + +/// +/// +/// +public class DefaultDidRetriever(HttpClient httpClient) : IDidRetriever +{ + private static readonly MediaTypeWithQualityHeaderValue DnsJsonHeader = new("application/dns-json"); + + /// + /// + /// + public async Task GetByDnsOrThrowAsync(Handle handle, CancellationToken ct) + { + if (handle is null) + { + throw new ArgumentNullException(nameof(handle)); + } + + try + { + var requestUri = new Uri($"https://cloudflare-dns.com/dns-query?name=_atproto.{handle.Value}&type=TXT"); + + using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + request.Headers.Accept.Add(DnsJsonHeader); + + using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + using var body = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + var dnsResponse = await JsonSerializer.DeserializeAsync(body, JsonContext.Default.DnsQueryResponse, ct).ConfigureAwait(false); + + var didString = ExtractDidFromDnsResponse(dnsResponse); + + if (string.IsNullOrWhiteSpace(didString)) + { + throw new InvalidOperationException($"No valid atproto DID TXT record found for handle '{handle.Value}'."); + } + + return Did.From(didString); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + throw new DidNotFoundException("Unable to get did. See inner exception", ex); + } + } + + /// + /// + /// + public async Task GetByWellKnownOrThrowAsync(Handle handle, CancellationToken ct) + { + if (handle is null) + { + throw new ArgumentNullException(nameof(handle)); + } + + try + { + var requestUri = new Uri($"https://{handle.Value}/.well-known/atproto-did"); + + using var response = await httpClient.GetAsync(requestUri, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var rawDid = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + var trimmedDid = rawDid?.Trim(); + + if (string.IsNullOrWhiteSpace(trimmedDid)) + { + throw new InvalidOperationException($"Endpoint '{requestUri}' returned an empty DID response."); + } + + return Did.From(trimmedDid); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + throw new DidNotFoundException("Unable to retrieve did. See inner exception", ex); + } + } + + private static string? ExtractDidFromDnsResponse(DnsQueryResponse? response) + { + if (response?.Answer is null) + { + return null; + } + + foreach (var answer in response.Answer) + { + if (string.IsNullOrWhiteSpace(answer.Data)) + { + continue; + } + + // Cloudflare wraps TXT records in quotes (e.g., "\"did=did:plc:...\"") + var text = answer.Data.Trim('\"'); + + if (text.StartsWith("did=", StringComparison.OrdinalIgnoreCase)) + { + return text.Substring(4).Trim(); + } + } + + return null; + } +} + +internal class DnsQueryResponse +{ + [JsonPropertyName("Answer")] + public List Answer { get; set; } = []; +} + +internal class Answer +{ + [JsonPropertyName("data")] + public string Data { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/StevanFreeborn.Atproto.IdentityResolver/Retrieval/IDidDocumentRetriever.cs b/src/StevanFreeborn.Atproto.IdentityResolver/Retrieval/IDidDocumentRetriever.cs new file mode 100644 index 0000000..031ae11 --- /dev/null +++ b/src/StevanFreeborn.Atproto.IdentityResolver/Retrieval/IDidDocumentRetriever.cs @@ -0,0 +1,19 @@ +using StevanFreeborn.Atproto.IdentityResolver.Identity; + +namespace StevanFreeborn.Atproto.IdentityResolver.Retrieval; + +/// +/// +/// +public interface IDidDocumentRetriever +{ + /// + /// + /// + Task GetByWellKnownOrThrowAsync(Handle handle, CancellationToken ct); + + /// + /// + /// + Task GetByPlcOrThrowAsync(Did did, CancellationToken ct); +} \ No newline at end of file diff --git a/src/StevanFreeborn.Atproto.IdentityResolver/Retrieval/IDidRetriever.cs b/src/StevanFreeborn.Atproto.IdentityResolver/Retrieval/IDidRetriever.cs new file mode 100644 index 0000000..5de46c6 --- /dev/null +++ b/src/StevanFreeborn.Atproto.IdentityResolver/Retrieval/IDidRetriever.cs @@ -0,0 +1,17 @@ +namespace StevanFreeborn.Atproto.IdentityResolver.Retrieval; + +/// +/// +/// +public interface IDidRetriever +{ + /// + /// + /// + Task GetByWellKnownOrThrowAsync(Handle handle, CancellationToken ct); + + /// + /// + /// + Task GetByDnsOrThrowAsync(Handle handle, CancellationToken ct); +} \ No newline at end of file diff --git a/tests/StevanFreeborn.Atproto.IdentityResolver.Tests/Unit/DefaultDidDocumentRetriever.cs b/tests/StevanFreeborn.Atproto.IdentityResolver.Tests/Unit/DefaultDidDocumentRetriever.cs new file mode 100644 index 0000000..7a0042e --- /dev/null +++ b/tests/StevanFreeborn.Atproto.IdentityResolver.Tests/Unit/DefaultDidDocumentRetriever.cs @@ -0,0 +1,26 @@ +using RichardSzalay.MockHttp; + +using StevanFreeborn.Atproto.IdentityResolver.Retrieval; + +namespace StevanFreeborn.Atproto.IdentityResolver.Tests.Unit; + +public sealed class DefaultDidDocumentRetrieverTests : IDisposable +{ + private readonly MockHttpMessageHandler _mockHttpMessageHandler = new(); + private readonly DefaultDidDocumentRetriever _sut; + + public DefaultDidDocumentRetrieverTests() + { + _sut = new(_mockHttpMessageHandler.ToHttpClient()); + } + + public void Dispose() + { + _mockHttpMessageHandler.Dispose(); + } + + [Fact] + public async Task GetByWellKnownOrThrowAsync_WhenResponse() + { + } +} \ No newline at end of file diff --git a/tests/StevanFreeborn.Atproto.IdentityResolver.Tests/Unit/DefaultDidRetrieverTests.cs b/tests/StevanFreeborn.Atproto.IdentityResolver.Tests/Unit/DefaultDidRetrieverTests.cs new file mode 100644 index 0000000..de22212 --- /dev/null +++ b/tests/StevanFreeborn.Atproto.IdentityResolver.Tests/Unit/DefaultDidRetrieverTests.cs @@ -0,0 +1,172 @@ +using System.Net; +using System.Text.Json; + +using StevanFreeborn.Atproto.IdentityResolver.Exceptions; + +using RichardSzalay.MockHttp; +using StevanFreeborn.Atproto.IdentityResolver.Retrieval; + +namespace StevanFreeborn.Atproto.IdentityResolver.Tests.Unit; + +public sealed class DefaultDidRetrieverTests : IDisposable +{ + private readonly MockHttpMessageHandler _mockHttpMessageHandler = new(); + private readonly DefaultDidRetriever _sut; + + public DefaultDidRetrieverTests() + { + _sut = new(_mockHttpMessageHandler.ToHttpClient()); + } + + public void Dispose() + { + _mockHttpMessageHandler.Dispose(); + } + + [Fact] + public async Task GetByDnsOrThrowAsync_WhenHandleIsNull_ItShouldThrowArgumentNullException() + { + var act = async () => await _sut.GetByDnsOrThrowAsync(null!, TestContext.Current.CancellationToken); + + var e = await act.Should().ThrowAsync(); + } + + [Fact] + public async Task GetByDnsOrThrowAsync_WhenResponseIsNotSuccessful_ItShouldThrowDidNotFoundException() + { + var handle = "stevan.cc"; + + _mockHttpMessageHandler + .When(HttpMethod.Get, $"https://cloudflare-dns.com/dns-query?name=_atproto.{handle}&type=TXT") + .Respond(HttpStatusCode.InternalServerError); + + var act = async () => await _sut.GetByDnsOrThrowAsync(Handle.From(handle), TestContext.Current.CancellationToken); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task GetByDnsOrThrowAsync_WhenResponseIsSuccessfulButDeserialziationFails_ItShouldThrowDidNotFoundException() + { + var handle = "stevan.cc"; + + _mockHttpMessageHandler + .When(HttpMethod.Get, $"https://cloudflare-dns.com/dns-query?name=_atproto.{handle}&type=TXT") + .Respond("application/json", JsonSerializer.Serialize("null")); + + var act = async () => await _sut.GetByDnsOrThrowAsync(Handle.From(handle), TestContext.Current.CancellationToken); + + var e = await act.Should().ThrowAsync(); + e.WithInnerException(); + } + + [Fact] + public async Task GetByDnsOrThrowAsync_WhenResponseIsSuccessfulButBodyIsNull_ItShouldThrowDidNotFoundException() + { + var handle = "stevan.cc"; + + _mockHttpMessageHandler + .When(HttpMethod.Get, $"https://cloudflare-dns.com/dns-query?name=_atproto.{handle}&type=TXT") + .Respond("application/json", "null"); + + var act = async () => await _sut.GetByDnsOrThrowAsync(Handle.From(handle), TestContext.Current.CancellationToken); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task GetByDnsOrThrowAsync_WhenResponseIsSuccessfulButNoAnswers_ItShouldThrowDidNotFoundException() + { + var handle = "stevan.cc"; + var response = new DnsQueryResponse(); + + _mockHttpMessageHandler + .When(HttpMethod.Get, $"https://cloudflare-dns.com/dns-query?name=_atproto.{handle}&type=TXT") + .Respond("application/json", JsonSerializer.Serialize(response)); + + var act = async () => await _sut.GetByDnsOrThrowAsync(Handle.From(handle), TestContext.Current.CancellationToken); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task GetByDnsOrThrowAsync_WhenResponseIsSuccessfulButTextRecordValueFormattedIncorrectly_ItShouldThrowDidNotFoundException() + { + var handle = "stevan.cc"; + var response = new DnsQueryResponse() + { + Answer = [new() { Data = $"did:web:{handle}" }] + }; + + _mockHttpMessageHandler + .When(HttpMethod.Get, $"https://cloudflare-dns.com/dns-query?name=_atproto.{handle}&type=TXT") + .Respond("application/json", JsonSerializer.Serialize(response)); + + var act = async () => await _sut.GetByDnsOrThrowAsync(Handle.From(handle), TestContext.Current.CancellationToken); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task GetByDnsOrThrowAsync_WhenResponseIsSuccessfulAndTextRecordValueFormattedCorrectly_ItShouldReturnDid() + { + var handle = "stevan.cc"; + var response = new DnsQueryResponse() + { + Answer = [new() { Data = $@"did=did:web:{handle}" }] + }; + + _mockHttpMessageHandler + .When(HttpMethod.Get, $"https://cloudflare-dns.com/dns-query?name=_atproto.{handle}&type=TXT") + .Respond("application/json", JsonSerializer.Serialize(response)); + + var result = await _sut.GetByDnsOrThrowAsync(Handle.From(handle), TestContext.Current.CancellationToken); + + result.Should().BeEquivalentTo(Did.From($"did:web:{handle}")); + } + + [Fact] + public async Task GetByWellKnownOrThrowAsync_WhenResponseIsUnsuccessful_ItShouldThrowDidNotFoundException() + { + var handle = "stevan.cc"; + var did = $"did:web:{handle}"; + + _mockHttpMessageHandler + .When(HttpMethod.Get, $"https://{handle}/.well-known/atproto-did") + .Respond(HttpStatusCode.InternalServerError); + + var act = async () => await _sut.GetByWellKnownOrThrowAsync(Handle.From(handle), TestContext.Current.CancellationToken); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task GetByWellKnownOrThrowAsync_WhenResponseIsSuccessfulAndButDoesNotContainDid_ItShouldThrowDidNotFoundException() + { + var handle = "stevan.cc"; + var did = $"did:web:{handle}"; + + _mockHttpMessageHandler + .When(HttpMethod.Get, $"https://{handle}/.well-known/atproto-did") + .Respond("application/json", JsonSerializer.Serialize(new { did })); + + var act = async () => await _sut.GetByWellKnownOrThrowAsync(Handle.From(handle), TestContext.Current.CancellationToken); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task GetByWellKnownOrThrowAsync_WhenResponseIsSuccessfulAndContainsDid_ItShouldReturnDid() + { + var handle = "stevan.cc"; + var did = $"did:web:{handle}"; + + _mockHttpMessageHandler + .When(HttpMethod.Get, $"https://{handle}/.well-known/atproto-did") + .Respond("text/plain", did); + + var result = await _sut.GetByWellKnownOrThrowAsync(Handle.From(handle), TestContext.Current.CancellationToken); + + result.Should().BeEquivalentTo(Did.From(did)); + } +} \ No newline at end of file