+1
-1
.formatter.exs
+1
-1
.formatter.exs
+3
CHANGELOG.md
+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
+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
+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
+13
lib/atex/http/response.ex
+41
lib/atex/identity_resolver.ex
+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
+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
+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
+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
+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
+2
mix.exs
+2
mix.lock
+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"},