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
···
# Used by "mix format"
[
inputs: ["{mix,.formatter,.credo}.exs", "{config,lib,test}/**/*.{ex,exs}"],
-
import_deps: [:typedstruct]
+
import_deps: [:typedstruct, :peri]
]
+3
CHANGELOG.md
···
### Added
- `Atex.HTTP` module that delegates to the currently configured adapter.
+
- `Atex.HTTP.Response` struct to be returned by `Atex.HTTP.Adapter`.
+
- `Atex.IdentityResolver` module for resolving and validating an identity,
+
either by DID or a handle.
## [0.2.0] - 2025-06-09
+3 -2
lib/atex/http/adapter.ex
···
@moduledoc """
Behaviour for defining a HTTP client adapter to be used within atex.
"""
+
alias Atex.HTTP.Response
-
@type success() :: {:ok, map()}
-
@type error() :: {:error, integer(), map()} | {:error, term()}
+
@type success() :: {:ok, Response.t()}
+
@type error() :: {:error, Response.t() | term()}
@type result() :: success() | error()
@callback get(url :: String.t(), opts :: keyword()) :: result()
+14 -3
lib/atex/http/adapter/req.ex
···
@behaviour Atex.HTTP.Adapter
+
@impl true
def get(url, opts) do
Req.get(url, opts) |> adapt()
end
+
@impl true
def post(url, opts) do
Req.post(url, opts) |> adapt()
end
-
defp adapt({:ok, %Req.Response{status: 200} = res}) do
-
{:ok, res.body}
+
@spec adapt({:ok, Req.Response.t()} | {:error, any()}) :: Atex.HTTP.Adapter.result()
+
defp adapt({:ok, %Req.Response{status: status} = res}) when status < 400 do
+
{:ok, to_response(res)}
end
defp adapt({:ok, %Req.Response{} = res}) do
-
{:error, res.status, res.body}
+
{:error, to_response(res)}
end
defp adapt({:error, exception}) do
{:error, exception}
+
end
+
+
defp to_response(%Req.Response{} = res) do
+
%Atex.HTTP.Response{
+
body: res.body,
+
status: res.status,
+
__raw__: res
+
}
end
end
+13
lib/atex/http/response.ex
···
+
defmodule Atex.HTTP.Response do
+
@moduledoc """
+
A generic response struct to be returned by an `Atex.HTTP.Adapter`.
+
"""
+
+
use TypedStruct
+
+
typedstruct enforce: true do
+
field :status, integer()
+
field :body, any()
+
field :__raw__, any()
+
end
+
end
+41
lib/atex/identity_resolver.ex
···
+
defmodule Atex.IdentityResolver do
+
alias Atex.IdentityResolver.{DID, DIDDocument, Handle}
+
+
@handle_strategy Application.compile_env(:atex, :handle_resolver_strategy, :dns_first)
+
+
# TODO: simplify errors
+
+
@spec resolve(identity :: String.t()) ::
+
{:ok, document :: DIDDocument.t(), did :: String.t(), handle :: String.t()}
+
| {:ok, DIDDocument.t()}
+
| {:error, :handle_mismatch}
+
| {:error, any()}
+
def resolve("did:" <> _ = did) do
+
with {:ok, document} <- DID.resolve(did),
+
:ok <- DIDDocument.validate_for_atproto(document, did) do
+
with handle when not is_nil(handle) <- DIDDocument.get_atproto_handle(document),
+
{:ok, handle_did} <- Handle.resolve(handle, @handle_strategy),
+
true <- handle_did == did do
+
{:ok, document, did, handle}
+
else
+
# Not having a handle, while a little un-ergonomic, is totally valid.
+
nil -> {:ok, document}
+
false -> {:error, :handle_mismatch}
+
e -> e
+
end
+
end
+
end
+
+
def resolve(handle) do
+
with {:ok, did} <- Handle.resolve(handle, @handle_strategy),
+
{:ok, document} <- DID.resolve(did),
+
did_handle when not is_nil(handle) <- DIDDocument.get_atproto_handle(document),
+
true <- did_handle == handle do
+
{:ok, document, did, handle}
+
else
+
nil -> {:error, :handle_mismatch}
+
false -> {:error, :handle_mismatch}
+
e -> e
+
end
+
end
+
end
+51
lib/atex/identity_resolver/did.ex
···
+
defmodule Atex.IdentityResolver.DID do
+
alias Atex.IdentityResolver.DIDDocument
+
+
@type resolution_result() ::
+
{:ok, DIDDocument.t()}
+
| {:error, :invalid_did_type | :invalid_did | :not_found | map() | atom() | any()}
+
+
@spec resolve(String.t()) :: resolution_result()
+
def resolve("did:plc:" <> _ = did), do: resolve_plc(did)
+
def resolve("did:web:" <> _ = did), do: resolve_web(did)
+
def resolve("did:" <> _), do: {:error, :invalid_did_type}
+
def resolve(_did), do: {:error, :invalid_did}
+
+
@spec resolve_plc(String.t()) :: resolution_result()
+
defp resolve_plc("did:plc:" <> _id = did) do
+
with {:ok, resp} when resp.status in 200..299 <-
+
Atex.HTTP.get("https://plc.directory/#{did}", []),
+
{:ok, body} <- decode_body(resp.body),
+
{:ok, document} <- DIDDocument.from_json(body),
+
:ok <- DIDDocument.validate_for_atproto(document, did) do
+
{:ok, document}
+
else
+
{:ok, %{status: status}} when status in [404, 410] -> {:error, :not_found}
+
{:ok, %{} = resp} -> {:error, resp}
+
e -> e
+
end
+
end
+
+
@spec resolve_web(String.t()) :: resolution_result()
+
defp resolve_web("did:web:" <> domain = did) do
+
with {:ok, resp} when resp.status in 200..299 <-
+
Atex.HTTP.get("https://#{domain}/.well-known/did.json", []),
+
{:ok, body} <- decode_body(resp.body),
+
{:ok, document} <- DIDDocument.from_json(body),
+
:ok <- DIDDocument.validate_for_atproto(document, did) do
+
{:ok, document}
+
else
+
{:ok, %{status: 404}} -> {:error, :not_found}
+
{:ok, %{} = resp} -> {:error, resp}
+
e -> e
+
end
+
end
+
+
@spec decode_body(any()) ::
+
{:ok, any()}
+
| {:error, :invalid_body | JSON.decode_error_reason()}
+
+
defp decode_body(body) when is_binary(body), do: JSON.decode(body)
+
defp decode_body(body) when is_map(body), do: {:ok, body}
+
defp decode_body(_body), do: {:error, :invalid_body}
+
end
+149
lib/atex/identity_resolver/did_document.ex
···
+
defmodule Atex.IdentityResolver.DIDDocument do
+
@moduledoc """
+
Struct and schema for describing and validating a [DID document](https://github.com/w3c/did-wg/blob/main/did-explainer.md#did-documents).
+
"""
+
import Peri
+
use TypedStruct
+
+
defschema :schema, %{
+
"@context": {:required, {:list, Atex.Peri.uri()}},
+
id: {:required, :string},
+
controller: {:either, {Atex.Peri.did(), {:list, Atex.Peri.did()}}},
+
also_known_as: {:list, Atex.Peri.uri()},
+
verification_method: {:list, get_schema(:verification_method)},
+
authentication: {:list, {:either, {Atex.Peri.uri(), get_schema(:verification_method)}}},
+
service: {:list, get_schema(:service)}
+
}
+
+
defschema :verification_method, %{
+
id: {:required, Atex.Peri.uri()},
+
type: {:required, :string},
+
controller: {:required, Atex.Peri.did()},
+
public_key_multibase: :string,
+
public_key_jwk: :map
+
}
+
+
defschema :service, %{
+
id: {:required, Atex.Peri.uri()},
+
type: {:required, {:either, {:string, {:list, :string}}}},
+
service_endpoint:
+
{:required,
+
{:oneof,
+
[
+
Atex.Peri.uri(),
+
{:map, Atex.Peri.uri()},
+
{:list, {:either, {Atex.Peri.uri(), {:map, Atex.Peri.uri()}}}}
+
]}}
+
}
+
+
@type verification_method() :: %{
+
required(:id) => String.t(),
+
required(:type) => String.t(),
+
required(:controller) => String.t(),
+
optional(:public_key_multibase) => String.t(),
+
optional(:public_key_jwk) => map()
+
}
+
+
@type service() :: %{
+
required(:id) => String.t(),
+
required(:type) => String.t() | list(String.t()),
+
required(:service_endpoint) =>
+
String.t()
+
| %{String.t() => String.t()}
+
| list(String.t() | %{String.t() => String.t()})
+
}
+
+
typedstruct do
+
field :"@context", list(String.t()), enforce: true
+
field :id, String.t(), enforce: true
+
field :controller, String.t() | list(String.t())
+
field :also_known_as, list(String.t())
+
field :verification_method, list(verification_method())
+
field :authentication, list(String.t() | verification_method())
+
field :service, list(service())
+
end
+
+
# Temporary until this issue is fixed: https://github.com/zoedsoupe/peri/issues/30
+
def new(params) do
+
params
+
|> Recase.Enumerable.atomize_keys(&Recase.to_snake/1)
+
|> then(&struct(__MODULE__, &1))
+
end
+
+
@spec from_json(map()) :: {:ok, t()} | {:error, Peri.Error.t()}
+
def from_json(%{} = map) do
+
map
+
# TODO: `atomize_keys` instead? Peri doesn't convert nested schemas to atoms but does for the base schema.
+
# Smells like a PR if I've ever smelt one...
+
|> Recase.Enumerable.convert_keys(&Recase.to_snake/1)
+
|> schema()
+
|> case do
+
# {:ok, params} -> {:ok, struct(__MODULE__, params)}
+
{:ok, params} -> {:ok, new(params)}
+
e -> e
+
end
+
end
+
+
@spec validate_for_atproto(t(), String.t()) :: any()
+
def validate_for_atproto(%__MODULE__{} = doc, did) do
+
# TODO: make sure this is ok
+
id_matches = doc.id == did
+
+
valid_signing_key =
+
Enum.any?(doc.verification_method, fn method ->
+
String.ends_with?(method.id, "#atproto") and method.controller == did
+
end)
+
+
valid_pds_service =
+
Enum.any?(doc.service, fn service ->
+
String.ends_with?(service.id, "#atproto_pds") and
+
service.type == "AtprotoPersonalDataServer" and
+
valid_pds_endpoint?(service.service_endpoint)
+
end)
+
+
case {id_matches, valid_signing_key, valid_pds_service} do
+
{true, true, true} -> :ok
+
{false, _, _} -> {:error, :id_mismatch}
+
{_, false, _} -> {:error, :no_signing_key}
+
{_, _, false} -> {:error, :invalid_pds}
+
end
+
end
+
+
@doc """
+
Get the associated ATProto handle in the DID document.
+
+
ATProto dictates that only the first valid handle is to be used, so this
+
follows that rule.
+
+
> #### Note {: .info}
+
>
+
> While DID documents are fairly authoritative, you need to make sure to
+
> validate the handle bidirectionally. See
+
> `Atex.IdentityResolver.Handle.resolve/2`.
+
"""
+
@spec get_atproto_handle(t()) :: String.t() | nil
+
def get_atproto_handle(%__MODULE__{also_known_as: nil}), do: nil
+
+
def get_atproto_handle(%__MODULE__{} = doc) do
+
Enum.find_value(doc.also_known_as, fn
+
# TODO: make sure no path or other URI parts
+
"at://" <> handle -> handle
+
_ -> nil
+
end)
+
end
+
+
defp valid_pds_endpoint?(endpoint) do
+
case URI.new(endpoint) do
+
{:ok, uri} ->
+
is_plain_uri =
+
uri
+
|> Map.from_struct()
+
|> Enum.all?(fn
+
{key, value} when key in [:userinfo, :path, :query, :fragment] -> is_nil(value)
+
_ -> true
+
end)
+
+
uri.scheme in ["https", "http"] and is_plain_uri
+
end
+
end
+
end
+74
lib/atex/identity_resolver/handle.ex
···
+
defmodule Atex.IdentityResolver.Handle do
+
@type strategy() :: :dns_first | :http_first | :race | :both
+
+
@spec resolve(String.t(), strategy()) ::
+
{:ok, String.t()} | :error | {:error, :ambiguous_handle}
+
def resolve(handle, strategy)
+
+
def resolve(handle, :dns_first) do
+
case resolve_via_dns(handle) do
+
:error -> resolve_via_http(handle)
+
ok -> ok
+
end
+
end
+
+
def resolve(handle, :http_first) do
+
case resolve_via_http(handle) do
+
:error -> resolve_via_dns(handle)
+
ok -> ok
+
end
+
end
+
+
def resolve(handle, :race) do
+
[&resolve_via_dns/1, &resolve_via_http/1]
+
|> Task.async_stream(& &1.(handle), max_concurrency: 2, ordered: false)
+
|> Stream.filter(&match?({:ok, {:ok, _}}, &1))
+
|> Enum.at(0)
+
end
+
+
def resolve(handle, :both) do
+
case Task.await_many([
+
Task.async(fn -> resolve_via_dns(handle) end),
+
Task.async(fn -> resolve_via_http(handle) end)
+
]) do
+
[{:ok, dns_did}, {:ok, http_did}] ->
+
if dns_did && http_did && dns_did != http_did do
+
{:error, :ambiguous_handle}
+
else
+
{:ok, dns_did}
+
end
+
+
_ ->
+
:error
+
end
+
end
+
+
@spec resolve_via_dns(String.t()) :: {:ok, String.t()} | :error
+
defp resolve_via_dns(handle) do
+
with ["did=" <> did] <- query_dns("_atproto.#{handle}", :txt),
+
"did:" <> _ <- did do
+
{:ok, did}
+
else
+
_ -> :error
+
end
+
end
+
+
@spec resolve_via_http(String.t()) :: {:ok, String.t()} | :error
+
defp resolve_via_http(handle) do
+
case Atex.HTTP.get("https://#{handle}/.well-known/atproto-did", []) do
+
{:ok, %{body: "did:" <> _ = did}} -> {:ok, did}
+
_ -> :error
+
end
+
end
+
+
@spec query_dns(String.t(), :inet_res.dns_rr_type()) :: list(String.t() | list(String.t()))
+
defp query_dns(domain, type) do
+
domain
+
|> String.to_charlist()
+
|> :inet_res.lookup(:in, type)
+
|> Enum.map(fn
+
[result] -> to_string(result)
+
result -> result
+
end)
+
end
+
end
+17
lib/atex/peri.ex
···
+
defmodule Atex.Peri do
+
@moduledoc """
+
Custom validators for Peri, for use within atex.
+
"""
+
+
def uri, do: {:custom, &validate_uri/1}
+
def did, do: {:string, {:regex, ~r/^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$/}}
+
+
defp validate_uri(uri) when is_binary(uri) do
+
case URI.new(uri) do
+
{:ok, _} -> :ok
+
{:error, _} -> {:error, "must be a valid URI", [uri: uri]}
+
end
+
end
+
+
defp validate_uri(uri), do: {:error, "must be a valid URI", [uri: uri]}
+
end
+2
mix.exs
···
defp deps do
[
+
{:peri, "~> 0.4"},
{:multiformats_ex, "~> 0.2"},
+
{:recase, "~> 0.5"},
{:req, "~> 0.5"},
{:typedstruct, "~> 0.5"},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
+2
mix.lock
···
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
+
"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"},
+
"recase": {:hex, :recase, "0.8.1", "ab98cd35857a86fa5ca99036f575241d71d77d9c2ab0c39aacf1c9b61f6f7d1d", [:mix], [], "hexpm", "9fd8d63e7e43bd9ea385b12364e305778b2bbd92537e95c4b2e26fc507d5e4c2"},
"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"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
"typedstruct": {:hex, :typedstruct, "0.5.3", "d68ae424251a41b81a8d0c485328ab48edbd3858f3565bbdac21b43c056fc9b4", [:make, :mix], [], "hexpm", "b53b8186701417c0b2782bf02a2db5524f879b8488f91d1d83b97d84c2943432"},