···11# Used by "mix format"
22[
33 inputs: ["{mix,.formatter,.credo}.exs", "{config,lib,test}/**/*.{ex,exs}"],
44- import_deps: [:typedstruct]
44+ import_deps: [:typedstruct, :peri]
55]
+3
CHANGELOG.md
···1515### Added
16161717- `Atex.HTTP` module that delegates to the currently configured adapter.
1818+- `Atex.HTTP.Response` struct to be returned by `Atex.HTTP.Adapter`.
1919+- `Atex.IdentityResolver` module for resolving and validating an identity,
2020+ either by DID or a handle.
18211922## [0.2.0] - 2025-06-09
2023
+3-2
lib/atex/http/adapter.ex
···22 @moduledoc """
33 Behaviour for defining a HTTP client adapter to be used within atex.
44 """
55+ alias Atex.HTTP.Response
5666- @type success() :: {:ok, map()}
77- @type error() :: {:error, integer(), map()} | {:error, term()}
77+ @type success() :: {:ok, Response.t()}
88+ @type error() :: {:error, Response.t() | term()}
89 @type result() :: success() | error()
9101011 @callback get(url :: String.t(), opts :: keyword()) :: result()
+14-3
lib/atex/http/adapter/req.ex
···5566 @behaviour Atex.HTTP.Adapter
7788+ @impl true
89 def get(url, opts) do
910 Req.get(url, opts) |> adapt()
1011 end
11121313+ @impl true
1214 def post(url, opts) do
1315 Req.post(url, opts) |> adapt()
1416 end
15171616- defp adapt({:ok, %Req.Response{status: 200} = res}) do
1717- {:ok, res.body}
1818+ @spec adapt({:ok, Req.Response.t()} | {:error, any()}) :: Atex.HTTP.Adapter.result()
1919+ defp adapt({:ok, %Req.Response{status: status} = res}) when status < 400 do
2020+ {:ok, to_response(res)}
1821 end
19222023 defp adapt({:ok, %Req.Response{} = res}) do
2121- {:error, res.status, res.body}
2424+ {:error, to_response(res)}
2225 end
23262427 defp adapt({:error, exception}) do
2528 {:error, exception}
2929+ end
3030+3131+ defp to_response(%Req.Response{} = res) do
3232+ %Atex.HTTP.Response{
3333+ body: res.body,
3434+ status: res.status,
3535+ __raw__: res
3636+ }
2637 end
2738end
+13
lib/atex/http/response.ex
···11+defmodule Atex.HTTP.Response do
22+ @moduledoc """
33+ A generic response struct to be returned by an `Atex.HTTP.Adapter`.
44+ """
55+66+ use TypedStruct
77+88+ typedstruct enforce: true do
99+ field :status, integer()
1010+ field :body, any()
1111+ field :__raw__, any()
1212+ end
1313+end
+41
lib/atex/identity_resolver.ex
···11+defmodule Atex.IdentityResolver do
22+ alias Atex.IdentityResolver.{DID, DIDDocument, Handle}
33+44+ @handle_strategy Application.compile_env(:atex, :handle_resolver_strategy, :dns_first)
55+66+ # TODO: simplify errors
77+88+ @spec resolve(identity :: String.t()) ::
99+ {:ok, document :: DIDDocument.t(), did :: String.t(), handle :: String.t()}
1010+ | {:ok, DIDDocument.t()}
1111+ | {:error, :handle_mismatch}
1212+ | {:error, any()}
1313+ def resolve("did:" <> _ = did) do
1414+ with {:ok, document} <- DID.resolve(did),
1515+ :ok <- DIDDocument.validate_for_atproto(document, did) do
1616+ with handle when not is_nil(handle) <- DIDDocument.get_atproto_handle(document),
1717+ {:ok, handle_did} <- Handle.resolve(handle, @handle_strategy),
1818+ true <- handle_did == did do
1919+ {:ok, document, did, handle}
2020+ else
2121+ # Not having a handle, while a little un-ergonomic, is totally valid.
2222+ nil -> {:ok, document}
2323+ false -> {:error, :handle_mismatch}
2424+ e -> e
2525+ end
2626+ end
2727+ end
2828+2929+ def resolve(handle) do
3030+ with {:ok, did} <- Handle.resolve(handle, @handle_strategy),
3131+ {:ok, document} <- DID.resolve(did),
3232+ did_handle when not is_nil(handle) <- DIDDocument.get_atproto_handle(document),
3333+ true <- did_handle == handle do
3434+ {:ok, document, did, handle}
3535+ else
3636+ nil -> {:error, :handle_mismatch}
3737+ false -> {:error, :handle_mismatch}
3838+ e -> e
3939+ end
4040+ end
4141+end
+51
lib/atex/identity_resolver/did.ex
···11+defmodule Atex.IdentityResolver.DID do
22+ alias Atex.IdentityResolver.DIDDocument
33+44+ @type resolution_result() ::
55+ {:ok, DIDDocument.t()}
66+ | {:error, :invalid_did_type | :invalid_did | :not_found | map() | atom() | any()}
77+88+ @spec resolve(String.t()) :: resolution_result()
99+ def resolve("did:plc:" <> _ = did), do: resolve_plc(did)
1010+ def resolve("did:web:" <> _ = did), do: resolve_web(did)
1111+ def resolve("did:" <> _), do: {:error, :invalid_did_type}
1212+ def resolve(_did), do: {:error, :invalid_did}
1313+1414+ @spec resolve_plc(String.t()) :: resolution_result()
1515+ defp resolve_plc("did:plc:" <> _id = did) do
1616+ with {:ok, resp} when resp.status in 200..299 <-
1717+ Atex.HTTP.get("https://plc.directory/#{did}", []),
1818+ {:ok, body} <- decode_body(resp.body),
1919+ {:ok, document} <- DIDDocument.from_json(body),
2020+ :ok <- DIDDocument.validate_for_atproto(document, did) do
2121+ {:ok, document}
2222+ else
2323+ {:ok, %{status: status}} when status in [404, 410] -> {:error, :not_found}
2424+ {:ok, %{} = resp} -> {:error, resp}
2525+ e -> e
2626+ end
2727+ end
2828+2929+ @spec resolve_web(String.t()) :: resolution_result()
3030+ defp resolve_web("did:web:" <> domain = did) do
3131+ with {:ok, resp} when resp.status in 200..299 <-
3232+ Atex.HTTP.get("https://#{domain}/.well-known/did.json", []),
3333+ {:ok, body} <- decode_body(resp.body),
3434+ {:ok, document} <- DIDDocument.from_json(body),
3535+ :ok <- DIDDocument.validate_for_atproto(document, did) do
3636+ {:ok, document}
3737+ else
3838+ {:ok, %{status: 404}} -> {:error, :not_found}
3939+ {:ok, %{} = resp} -> {:error, resp}
4040+ e -> e
4141+ end
4242+ end
4343+4444+ @spec decode_body(any()) ::
4545+ {:ok, any()}
4646+ | {:error, :invalid_body | JSON.decode_error_reason()}
4747+4848+ defp decode_body(body) when is_binary(body), do: JSON.decode(body)
4949+ defp decode_body(body) when is_map(body), do: {:ok, body}
5050+ defp decode_body(_body), do: {:error, :invalid_body}
5151+end
+149
lib/atex/identity_resolver/did_document.ex
···11+defmodule Atex.IdentityResolver.DIDDocument do
22+ @moduledoc """
33+ Struct and schema for describing and validating a [DID document](https://github.com/w3c/did-wg/blob/main/did-explainer.md#did-documents).
44+ """
55+ import Peri
66+ use TypedStruct
77+88+ defschema :schema, %{
99+ "@context": {:required, {:list, Atex.Peri.uri()}},
1010+ id: {:required, :string},
1111+ controller: {:either, {Atex.Peri.did(), {:list, Atex.Peri.did()}}},
1212+ also_known_as: {:list, Atex.Peri.uri()},
1313+ verification_method: {:list, get_schema(:verification_method)},
1414+ authentication: {:list, {:either, {Atex.Peri.uri(), get_schema(:verification_method)}}},
1515+ service: {:list, get_schema(:service)}
1616+ }
1717+1818+ defschema :verification_method, %{
1919+ id: {:required, Atex.Peri.uri()},
2020+ type: {:required, :string},
2121+ controller: {:required, Atex.Peri.did()},
2222+ public_key_multibase: :string,
2323+ public_key_jwk: :map
2424+ }
2525+2626+ defschema :service, %{
2727+ id: {:required, Atex.Peri.uri()},
2828+ type: {:required, {:either, {:string, {:list, :string}}}},
2929+ service_endpoint:
3030+ {:required,
3131+ {:oneof,
3232+ [
3333+ Atex.Peri.uri(),
3434+ {:map, Atex.Peri.uri()},
3535+ {:list, {:either, {Atex.Peri.uri(), {:map, Atex.Peri.uri()}}}}
3636+ ]}}
3737+ }
3838+3939+ @type verification_method() :: %{
4040+ required(:id) => String.t(),
4141+ required(:type) => String.t(),
4242+ required(:controller) => String.t(),
4343+ optional(:public_key_multibase) => String.t(),
4444+ optional(:public_key_jwk) => map()
4545+ }
4646+4747+ @type service() :: %{
4848+ required(:id) => String.t(),
4949+ required(:type) => String.t() | list(String.t()),
5050+ required(:service_endpoint) =>
5151+ String.t()
5252+ | %{String.t() => String.t()}
5353+ | list(String.t() | %{String.t() => String.t()})
5454+ }
5555+5656+ typedstruct do
5757+ field :"@context", list(String.t()), enforce: true
5858+ field :id, String.t(), enforce: true
5959+ field :controller, String.t() | list(String.t())
6060+ field :also_known_as, list(String.t())
6161+ field :verification_method, list(verification_method())
6262+ field :authentication, list(String.t() | verification_method())
6363+ field :service, list(service())
6464+ end
6565+6666+ # Temporary until this issue is fixed: https://github.com/zoedsoupe/peri/issues/30
6767+ def new(params) do
6868+ params
6969+ |> Recase.Enumerable.atomize_keys(&Recase.to_snake/1)
7070+ |> then(&struct(__MODULE__, &1))
7171+ end
7272+7373+ @spec from_json(map()) :: {:ok, t()} | {:error, Peri.Error.t()}
7474+ def from_json(%{} = map) do
7575+ map
7676+ # TODO: `atomize_keys` instead? Peri doesn't convert nested schemas to atoms but does for the base schema.
7777+ # Smells like a PR if I've ever smelt one...
7878+ |> Recase.Enumerable.convert_keys(&Recase.to_snake/1)
7979+ |> schema()
8080+ |> case do
8181+ # {:ok, params} -> {:ok, struct(__MODULE__, params)}
8282+ {:ok, params} -> {:ok, new(params)}
8383+ e -> e
8484+ end
8585+ end
8686+8787+ @spec validate_for_atproto(t(), String.t()) :: any()
8888+ def validate_for_atproto(%__MODULE__{} = doc, did) do
8989+ # TODO: make sure this is ok
9090+ id_matches = doc.id == did
9191+9292+ valid_signing_key =
9393+ Enum.any?(doc.verification_method, fn method ->
9494+ String.ends_with?(method.id, "#atproto") and method.controller == did
9595+ end)
9696+9797+ valid_pds_service =
9898+ Enum.any?(doc.service, fn service ->
9999+ String.ends_with?(service.id, "#atproto_pds") and
100100+ service.type == "AtprotoPersonalDataServer" and
101101+ valid_pds_endpoint?(service.service_endpoint)
102102+ end)
103103+104104+ case {id_matches, valid_signing_key, valid_pds_service} do
105105+ {true, true, true} -> :ok
106106+ {false, _, _} -> {:error, :id_mismatch}
107107+ {_, false, _} -> {:error, :no_signing_key}
108108+ {_, _, false} -> {:error, :invalid_pds}
109109+ end
110110+ end
111111+112112+ @doc """
113113+ Get the associated ATProto handle in the DID document.
114114+115115+ ATProto dictates that only the first valid handle is to be used, so this
116116+ follows that rule.
117117+118118+ > #### Note {: .info}
119119+ >
120120+ > While DID documents are fairly authoritative, you need to make sure to
121121+ > validate the handle bidirectionally. See
122122+ > `Atex.IdentityResolver.Handle.resolve/2`.
123123+ """
124124+ @spec get_atproto_handle(t()) :: String.t() | nil
125125+ def get_atproto_handle(%__MODULE__{also_known_as: nil}), do: nil
126126+127127+ def get_atproto_handle(%__MODULE__{} = doc) do
128128+ Enum.find_value(doc.also_known_as, fn
129129+ # TODO: make sure no path or other URI parts
130130+ "at://" <> handle -> handle
131131+ _ -> nil
132132+ end)
133133+ end
134134+135135+ defp valid_pds_endpoint?(endpoint) do
136136+ case URI.new(endpoint) do
137137+ {:ok, uri} ->
138138+ is_plain_uri =
139139+ uri
140140+ |> Map.from_struct()
141141+ |> Enum.all?(fn
142142+ {key, value} when key in [:userinfo, :path, :query, :fragment] -> is_nil(value)
143143+ _ -> true
144144+ end)
145145+146146+ uri.scheme in ["https", "http"] and is_plain_uri
147147+ end
148148+ end
149149+end
+74
lib/atex/identity_resolver/handle.ex
···11+defmodule Atex.IdentityResolver.Handle do
22+ @type strategy() :: :dns_first | :http_first | :race | :both
33+44+ @spec resolve(String.t(), strategy()) ::
55+ {:ok, String.t()} | :error | {:error, :ambiguous_handle}
66+ def resolve(handle, strategy)
77+88+ def resolve(handle, :dns_first) do
99+ case resolve_via_dns(handle) do
1010+ :error -> resolve_via_http(handle)
1111+ ok -> ok
1212+ end
1313+ end
1414+1515+ def resolve(handle, :http_first) do
1616+ case resolve_via_http(handle) do
1717+ :error -> resolve_via_dns(handle)
1818+ ok -> ok
1919+ end
2020+ end
2121+2222+ def resolve(handle, :race) do
2323+ [&resolve_via_dns/1, &resolve_via_http/1]
2424+ |> Task.async_stream(& &1.(handle), max_concurrency: 2, ordered: false)
2525+ |> Stream.filter(&match?({:ok, {:ok, _}}, &1))
2626+ |> Enum.at(0)
2727+ end
2828+2929+ def resolve(handle, :both) do
3030+ case Task.await_many([
3131+ Task.async(fn -> resolve_via_dns(handle) end),
3232+ Task.async(fn -> resolve_via_http(handle) end)
3333+ ]) do
3434+ [{:ok, dns_did}, {:ok, http_did}] ->
3535+ if dns_did && http_did && dns_did != http_did do
3636+ {:error, :ambiguous_handle}
3737+ else
3838+ {:ok, dns_did}
3939+ end
4040+4141+ _ ->
4242+ :error
4343+ end
4444+ end
4545+4646+ @spec resolve_via_dns(String.t()) :: {:ok, String.t()} | :error
4747+ defp resolve_via_dns(handle) do
4848+ with ["did=" <> did] <- query_dns("_atproto.#{handle}", :txt),
4949+ "did:" <> _ <- did do
5050+ {:ok, did}
5151+ else
5252+ _ -> :error
5353+ end
5454+ end
5555+5656+ @spec resolve_via_http(String.t()) :: {:ok, String.t()} | :error
5757+ defp resolve_via_http(handle) do
5858+ case Atex.HTTP.get("https://#{handle}/.well-known/atproto-did", []) do
5959+ {:ok, %{body: "did:" <> _ = did}} -> {:ok, did}
6060+ _ -> :error
6161+ end
6262+ end
6363+6464+ @spec query_dns(String.t(), :inet_res.dns_rr_type()) :: list(String.t() | list(String.t()))
6565+ defp query_dns(domain, type) do
6666+ domain
6767+ |> String.to_charlist()
6868+ |> :inet_res.lookup(:in, type)
6969+ |> Enum.map(fn
7070+ [result] -> to_string(result)
7171+ result -> result
7272+ end)
7373+ end
7474+end
+17
lib/atex/peri.ex
···11+defmodule Atex.Peri do
22+ @moduledoc """
33+ Custom validators for Peri, for use within atex.
44+ """
55+66+ def uri, do: {:custom, &validate_uri/1}
77+ def did, do: {:string, {:regex, ~r/^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$/}}
88+99+ defp validate_uri(uri) when is_binary(uri) do
1010+ case URI.new(uri) do
1111+ {:ok, _} -> :ok
1212+ {:error, _} -> {:error, "must be a valid URI", [uri: uri]}
1313+ end
1414+ end
1515+1616+ defp validate_uri(uri), do: {:error, "must be a valid URI", [uri: uri]}
1717+end