A set of utilities for working with the AT Protocol in Elixir.

feat: identity resolver module for DIDs and handles

ovyerus.com d7380b38 90e12b57

verified
+1 -1
.formatter.exs
··· 1 1 # Used by "mix format" 2 2 [ 3 3 inputs: ["{mix,.formatter,.credo}.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 - import_deps: [:typedstruct] 4 + import_deps: [:typedstruct, :peri] 5 5 ]
+3
CHANGELOG.md
··· 15 15 ### Added 16 16 17 17 - `Atex.HTTP` module that delegates to the currently configured adapter. 18 + - `Atex.HTTP.Response` struct to be returned by `Atex.HTTP.Adapter`. 19 + - `Atex.IdentityResolver` module for resolving and validating an identity, 20 + either by DID or a handle. 18 21 19 22 ## [0.2.0] - 2025-06-09 20 23
+3 -2
lib/atex/http/adapter.ex
··· 2 2 @moduledoc """ 3 3 Behaviour for defining a HTTP client adapter to be used within atex. 4 4 """ 5 + alias Atex.HTTP.Response 5 6 6 - @type success() :: {:ok, map()} 7 - @type error() :: {:error, integer(), map()} | {:error, term()} 7 + @type success() :: {:ok, Response.t()} 8 + @type error() :: {:error, Response.t() | term()} 8 9 @type result() :: success() | error() 9 10 10 11 @callback get(url :: String.t(), opts :: keyword()) :: result()
+14 -3
lib/atex/http/adapter/req.ex
··· 5 5 6 6 @behaviour Atex.HTTP.Adapter 7 7 8 + @impl true 8 9 def get(url, opts) do 9 10 Req.get(url, opts) |> adapt() 10 11 end 11 12 13 + @impl true 12 14 def post(url, opts) do 13 15 Req.post(url, opts) |> adapt() 14 16 end 15 17 16 - defp adapt({:ok, %Req.Response{status: 200} = res}) do 17 - {:ok, res.body} 18 + @spec adapt({:ok, Req.Response.t()} | {:error, any()}) :: Atex.HTTP.Adapter.result() 19 + defp adapt({:ok, %Req.Response{status: status} = res}) when status < 400 do 20 + {:ok, to_response(res)} 18 21 end 19 22 20 23 defp adapt({:ok, %Req.Response{} = res}) do 21 - {:error, res.status, res.body} 24 + {:error, to_response(res)} 22 25 end 23 26 24 27 defp adapt({:error, exception}) do 25 28 {:error, exception} 29 + end 30 + 31 + defp to_response(%Req.Response{} = res) do 32 + %Atex.HTTP.Response{ 33 + body: res.body, 34 + status: res.status, 35 + __raw__: res 36 + } 26 37 end 27 38 end
+13
lib/atex/http/response.ex
··· 1 + defmodule Atex.HTTP.Response do 2 + @moduledoc """ 3 + A generic response struct to be returned by an `Atex.HTTP.Adapter`. 4 + """ 5 + 6 + use TypedStruct 7 + 8 + typedstruct enforce: true do 9 + field :status, integer() 10 + field :body, any() 11 + field :__raw__, any() 12 + end 13 + end
+41
lib/atex/identity_resolver.ex
··· 1 + defmodule Atex.IdentityResolver do 2 + alias Atex.IdentityResolver.{DID, DIDDocument, Handle} 3 + 4 + @handle_strategy Application.compile_env(:atex, :handle_resolver_strategy, :dns_first) 5 + 6 + # TODO: simplify errors 7 + 8 + @spec resolve(identity :: String.t()) :: 9 + {:ok, document :: DIDDocument.t(), did :: String.t(), handle :: String.t()} 10 + | {:ok, DIDDocument.t()} 11 + | {:error, :handle_mismatch} 12 + | {:error, any()} 13 + def resolve("did:" <> _ = did) do 14 + with {:ok, document} <- DID.resolve(did), 15 + :ok <- DIDDocument.validate_for_atproto(document, did) do 16 + with handle when not is_nil(handle) <- DIDDocument.get_atproto_handle(document), 17 + {:ok, handle_did} <- Handle.resolve(handle, @handle_strategy), 18 + true <- handle_did == did do 19 + {:ok, document, did, handle} 20 + else 21 + # Not having a handle, while a little un-ergonomic, is totally valid. 22 + nil -> {:ok, document} 23 + false -> {:error, :handle_mismatch} 24 + e -> e 25 + end 26 + end 27 + end 28 + 29 + def resolve(handle) do 30 + with {:ok, did} <- Handle.resolve(handle, @handle_strategy), 31 + {:ok, document} <- DID.resolve(did), 32 + did_handle when not is_nil(handle) <- DIDDocument.get_atproto_handle(document), 33 + true <- did_handle == handle do 34 + {:ok, document, did, handle} 35 + else 36 + nil -> {:error, :handle_mismatch} 37 + false -> {:error, :handle_mismatch} 38 + e -> e 39 + end 40 + end 41 + end
+51
lib/atex/identity_resolver/did.ex
··· 1 + defmodule Atex.IdentityResolver.DID do 2 + alias Atex.IdentityResolver.DIDDocument 3 + 4 + @type resolution_result() :: 5 + {:ok, DIDDocument.t()} 6 + | {:error, :invalid_did_type | :invalid_did | :not_found | map() | atom() | any()} 7 + 8 + @spec resolve(String.t()) :: resolution_result() 9 + def resolve("did:plc:" <> _ = did), do: resolve_plc(did) 10 + def resolve("did:web:" <> _ = did), do: resolve_web(did) 11 + def resolve("did:" <> _), do: {:error, :invalid_did_type} 12 + def resolve(_did), do: {:error, :invalid_did} 13 + 14 + @spec resolve_plc(String.t()) :: resolution_result() 15 + defp resolve_plc("did:plc:" <> _id = did) do 16 + with {:ok, resp} when resp.status in 200..299 <- 17 + Atex.HTTP.get("https://plc.directory/#{did}", []), 18 + {:ok, body} <- decode_body(resp.body), 19 + {:ok, document} <- DIDDocument.from_json(body), 20 + :ok <- DIDDocument.validate_for_atproto(document, did) do 21 + {:ok, document} 22 + else 23 + {:ok, %{status: status}} when status in [404, 410] -> {:error, :not_found} 24 + {:ok, %{} = resp} -> {:error, resp} 25 + e -> e 26 + end 27 + end 28 + 29 + @spec resolve_web(String.t()) :: resolution_result() 30 + defp resolve_web("did:web:" <> domain = did) do 31 + with {:ok, resp} when resp.status in 200..299 <- 32 + Atex.HTTP.get("https://#{domain}/.well-known/did.json", []), 33 + {:ok, body} <- decode_body(resp.body), 34 + {:ok, document} <- DIDDocument.from_json(body), 35 + :ok <- DIDDocument.validate_for_atproto(document, did) do 36 + {:ok, document} 37 + else 38 + {:ok, %{status: 404}} -> {:error, :not_found} 39 + {:ok, %{} = resp} -> {:error, resp} 40 + e -> e 41 + end 42 + end 43 + 44 + @spec decode_body(any()) :: 45 + {:ok, any()} 46 + | {:error, :invalid_body | JSON.decode_error_reason()} 47 + 48 + defp decode_body(body) when is_binary(body), do: JSON.decode(body) 49 + defp decode_body(body) when is_map(body), do: {:ok, body} 50 + defp decode_body(_body), do: {:error, :invalid_body} 51 + end
+149
lib/atex/identity_resolver/did_document.ex
··· 1 + defmodule Atex.IdentityResolver.DIDDocument do 2 + @moduledoc """ 3 + Struct and schema for describing and validating a [DID document](https://github.com/w3c/did-wg/blob/main/did-explainer.md#did-documents). 4 + """ 5 + import Peri 6 + use TypedStruct 7 + 8 + defschema :schema, %{ 9 + "@context": {:required, {:list, Atex.Peri.uri()}}, 10 + id: {:required, :string}, 11 + controller: {:either, {Atex.Peri.did(), {:list, Atex.Peri.did()}}}, 12 + also_known_as: {:list, Atex.Peri.uri()}, 13 + verification_method: {:list, get_schema(:verification_method)}, 14 + authentication: {:list, {:either, {Atex.Peri.uri(), get_schema(:verification_method)}}}, 15 + service: {:list, get_schema(:service)} 16 + } 17 + 18 + defschema :verification_method, %{ 19 + id: {:required, Atex.Peri.uri()}, 20 + type: {:required, :string}, 21 + controller: {:required, Atex.Peri.did()}, 22 + public_key_multibase: :string, 23 + public_key_jwk: :map 24 + } 25 + 26 + defschema :service, %{ 27 + id: {:required, Atex.Peri.uri()}, 28 + type: {:required, {:either, {:string, {:list, :string}}}}, 29 + service_endpoint: 30 + {:required, 31 + {:oneof, 32 + [ 33 + Atex.Peri.uri(), 34 + {:map, Atex.Peri.uri()}, 35 + {:list, {:either, {Atex.Peri.uri(), {:map, Atex.Peri.uri()}}}} 36 + ]}} 37 + } 38 + 39 + @type verification_method() :: %{ 40 + required(:id) => String.t(), 41 + required(:type) => String.t(), 42 + required(:controller) => String.t(), 43 + optional(:public_key_multibase) => String.t(), 44 + optional(:public_key_jwk) => map() 45 + } 46 + 47 + @type service() :: %{ 48 + required(:id) => String.t(), 49 + required(:type) => String.t() | list(String.t()), 50 + required(:service_endpoint) => 51 + String.t() 52 + | %{String.t() => String.t()} 53 + | list(String.t() | %{String.t() => String.t()}) 54 + } 55 + 56 + typedstruct do 57 + field :"@context", list(String.t()), enforce: true 58 + field :id, String.t(), enforce: true 59 + field :controller, String.t() | list(String.t()) 60 + field :also_known_as, list(String.t()) 61 + field :verification_method, list(verification_method()) 62 + field :authentication, list(String.t() | verification_method()) 63 + field :service, list(service()) 64 + end 65 + 66 + # Temporary until this issue is fixed: https://github.com/zoedsoupe/peri/issues/30 67 + def new(params) do 68 + params 69 + |> Recase.Enumerable.atomize_keys(&Recase.to_snake/1) 70 + |> then(&struct(__MODULE__, &1)) 71 + end 72 + 73 + @spec from_json(map()) :: {:ok, t()} | {:error, Peri.Error.t()} 74 + def from_json(%{} = map) do 75 + map 76 + # TODO: `atomize_keys` instead? Peri doesn't convert nested schemas to atoms but does for the base schema. 77 + # Smells like a PR if I've ever smelt one... 78 + |> Recase.Enumerable.convert_keys(&Recase.to_snake/1) 79 + |> schema() 80 + |> case do 81 + # {:ok, params} -> {:ok, struct(__MODULE__, params)} 82 + {:ok, params} -> {:ok, new(params)} 83 + e -> e 84 + end 85 + end 86 + 87 + @spec validate_for_atproto(t(), String.t()) :: any() 88 + def validate_for_atproto(%__MODULE__{} = doc, did) do 89 + # TODO: make sure this is ok 90 + id_matches = doc.id == did 91 + 92 + valid_signing_key = 93 + Enum.any?(doc.verification_method, fn method -> 94 + String.ends_with?(method.id, "#atproto") and method.controller == did 95 + end) 96 + 97 + valid_pds_service = 98 + Enum.any?(doc.service, fn service -> 99 + String.ends_with?(service.id, "#atproto_pds") and 100 + service.type == "AtprotoPersonalDataServer" and 101 + valid_pds_endpoint?(service.service_endpoint) 102 + end) 103 + 104 + case {id_matches, valid_signing_key, valid_pds_service} do 105 + {true, true, true} -> :ok 106 + {false, _, _} -> {:error, :id_mismatch} 107 + {_, false, _} -> {:error, :no_signing_key} 108 + {_, _, false} -> {:error, :invalid_pds} 109 + end 110 + end 111 + 112 + @doc """ 113 + Get the associated ATProto handle in the DID document. 114 + 115 + ATProto dictates that only the first valid handle is to be used, so this 116 + follows that rule. 117 + 118 + > #### Note {: .info} 119 + > 120 + > While DID documents are fairly authoritative, you need to make sure to 121 + > validate the handle bidirectionally. See 122 + > `Atex.IdentityResolver.Handle.resolve/2`. 123 + """ 124 + @spec get_atproto_handle(t()) :: String.t() | nil 125 + def get_atproto_handle(%__MODULE__{also_known_as: nil}), do: nil 126 + 127 + def get_atproto_handle(%__MODULE__{} = doc) do 128 + Enum.find_value(doc.also_known_as, fn 129 + # TODO: make sure no path or other URI parts 130 + "at://" <> handle -> handle 131 + _ -> nil 132 + end) 133 + end 134 + 135 + defp valid_pds_endpoint?(endpoint) do 136 + case URI.new(endpoint) do 137 + {:ok, uri} -> 138 + is_plain_uri = 139 + uri 140 + |> Map.from_struct() 141 + |> Enum.all?(fn 142 + {key, value} when key in [:userinfo, :path, :query, :fragment] -> is_nil(value) 143 + _ -> true 144 + end) 145 + 146 + uri.scheme in ["https", "http"] and is_plain_uri 147 + end 148 + end 149 + end
+74
lib/atex/identity_resolver/handle.ex
··· 1 + defmodule Atex.IdentityResolver.Handle do 2 + @type strategy() :: :dns_first | :http_first | :race | :both 3 + 4 + @spec resolve(String.t(), strategy()) :: 5 + {:ok, String.t()} | :error | {:error, :ambiguous_handle} 6 + def resolve(handle, strategy) 7 + 8 + def resolve(handle, :dns_first) do 9 + case resolve_via_dns(handle) do 10 + :error -> resolve_via_http(handle) 11 + ok -> ok 12 + end 13 + end 14 + 15 + def resolve(handle, :http_first) do 16 + case resolve_via_http(handle) do 17 + :error -> resolve_via_dns(handle) 18 + ok -> ok 19 + end 20 + end 21 + 22 + def resolve(handle, :race) do 23 + [&resolve_via_dns/1, &resolve_via_http/1] 24 + |> Task.async_stream(& &1.(handle), max_concurrency: 2, ordered: false) 25 + |> Stream.filter(&match?({:ok, {:ok, _}}, &1)) 26 + |> Enum.at(0) 27 + end 28 + 29 + def resolve(handle, :both) do 30 + case Task.await_many([ 31 + Task.async(fn -> resolve_via_dns(handle) end), 32 + Task.async(fn -> resolve_via_http(handle) end) 33 + ]) do 34 + [{:ok, dns_did}, {:ok, http_did}] -> 35 + if dns_did && http_did && dns_did != http_did do 36 + {:error, :ambiguous_handle} 37 + else 38 + {:ok, dns_did} 39 + end 40 + 41 + _ -> 42 + :error 43 + end 44 + end 45 + 46 + @spec resolve_via_dns(String.t()) :: {:ok, String.t()} | :error 47 + defp resolve_via_dns(handle) do 48 + with ["did=" <> did] <- query_dns("_atproto.#{handle}", :txt), 49 + "did:" <> _ <- did do 50 + {:ok, did} 51 + else 52 + _ -> :error 53 + end 54 + end 55 + 56 + @spec resolve_via_http(String.t()) :: {:ok, String.t()} | :error 57 + defp resolve_via_http(handle) do 58 + case Atex.HTTP.get("https://#{handle}/.well-known/atproto-did", []) do 59 + {:ok, %{body: "did:" <> _ = did}} -> {:ok, did} 60 + _ -> :error 61 + end 62 + end 63 + 64 + @spec query_dns(String.t(), :inet_res.dns_rr_type()) :: list(String.t() | list(String.t())) 65 + defp query_dns(domain, type) do 66 + domain 67 + |> String.to_charlist() 68 + |> :inet_res.lookup(:in, type) 69 + |> Enum.map(fn 70 + [result] -> to_string(result) 71 + result -> result 72 + end) 73 + end 74 + end
+17
lib/atex/peri.ex
··· 1 + defmodule Atex.Peri do 2 + @moduledoc """ 3 + Custom validators for Peri, for use within atex. 4 + """ 5 + 6 + def uri, do: {:custom, &validate_uri/1} 7 + def did, do: {:string, {:regex, ~r/^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$/}} 8 + 9 + defp validate_uri(uri) when is_binary(uri) do 10 + case URI.new(uri) do 11 + {:ok, _} -> :ok 12 + {:error, _} -> {:error, "must be a valid URI", [uri: uri]} 13 + end 14 + end 15 + 16 + defp validate_uri(uri), do: {:error, "must be a valid URI", [uri: uri]} 17 + end
+2
mix.exs
··· 26 26 27 27 defp deps do 28 28 [ 29 + {:peri, "~> 0.4"}, 29 30 {:multiformats_ex, "~> 0.2"}, 31 + {:recase, "~> 0.5"}, 30 32 {:req, "~> 0.5"}, 31 33 {:typedstruct, "~> 0.5"}, 32 34 {:credo, "~> 1.7", only: [:dev, :test], runtime: false},
+2
mix.lock
··· 16 16 "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 17 17 "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 18 18 "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, 19 + "peri": {:hex, :peri, "0.4.0", "eaa0c0bcf878f70d0bea71c63102f667ee0568f02ec0a97a98a8b30d8563f3aa", [:mix], [{:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.1", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "ce1835dc5e202b6c7608100ee32df569965fa5775a75100ada7a82260d46c1a8"}, 20 + "recase": {:hex, :recase, "0.8.1", "ab98cd35857a86fa5ca99036f575241d71d77d9c2ab0c39aacf1c9b61f6f7d1d", [:mix], [], "hexpm", "9fd8d63e7e43bd9ea385b12364e305778b2bbd92537e95c4b2e26fc507d5e4c2"}, 19 21 "req": {:hex, :req, "0.5.10", "a3a063eab8b7510785a467f03d30a8d95f66f5c3d9495be3474b61459c54376c", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "8a604815743f8a2d3b5de0659fa3137fa4b1cffd636ecb69b30b2b9b2c2559be"}, 20 22 "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 21 23 "typedstruct": {:hex, :typedstruct, "0.5.3", "d68ae424251a41b81a8d0c485328ab48edbd3858f3565bbdac21b43c056fc9b4", [:make, :mix], [], "hexpm", "b53b8186701417c0b2782bf02a2db5524f879b8488f91d1d83b97d84c2943432"},