From f2488225586962cc5fc75fd15475c49faa4bafc8 Mon Sep 17 00:00:00 2001 From: Johanna Larsson Date: Sun, 23 Aug 2026 10:23:55 +0100 Subject: [PATCH] Add support for resolving DIDs And promotes resolve_handle and resolve_did to top level functions, accepting `name` as their first argument Identity.ex now has good test coverage too. --- lib/latch.ex | 40 ++++++++++++++++ lib/latch/error/handle_not_found.ex | 1 - lib/latch/error/identity_mismatch.ex | 2 +- lib/latch/error/unsupported_did_method.ex | 14 ++++++ lib/latch/identity.ex | 49 ++++++++++++++++--- test/latch/identity_test.exs | 57 +++++++++++++++++++++-- 6 files changed, 151 insertions(+), 12 deletions(-) create mode 100644 lib/latch/error/unsupported_did_method.ex diff --git a/lib/latch.ex b/lib/latch.ex index 2f3c02a..34f2ae0 100644 --- a/lib/latch.ex +++ b/lib/latch.ex @@ -319,6 +319,46 @@ defmodule Latch do Latch.Client.upload_blob(config, did, bytes, content_type, opts) end + @doc """ + Resolves and verifies a handle, returning its DID and PDS endpoint. + + Returns structured `Latch.Error` exceptions describing resolution, + identity-verifiction, and transport failures. + """ + @spec resolve_handle(name(), String.t()) :: + {:ok, Identity.t()} + | {:error, + HandleNotFound.t() + | IdentityMismatch.t() + | InvalidResponse.t() + | Transport.t() + | UnsupportedDIDMethod.t()} + def resolve_handle(name, handle) do + %Config{} = config = config(name) + + Identity.resolve_handle(config, handle) + end + + @doc """ + Resolves and verifies a DID, returning the handle and PDS endpoint. + + Returns structured `Latch.Error` exceptions describing resolution, + identity-verifiction, and transport failures. + """ + @spec resolve_did(name(), String.t()) :: + {:ok, Identity.t()} + | {:error, + HandleNotFound.t() + | IdentityMismatch.t() + | InvalidResponse.t() + | Transport.t() + | UnsupportedDIDMethod.t()} + def resolve_did(name, did) do + %Config{} = config = config(name) + + Identity.resolve_did(config, did) + end + defp complete_callback( %{"error" => error, "iss" => issuer} = params, request, diff --git a/lib/latch/error/handle_not_found.ex b/lib/latch/error/handle_not_found.ex index a183f0f..d04a771 100644 --- a/lib/latch/error/handle_not_found.ex +++ b/lib/latch/error/handle_not_found.ex @@ -7,7 +7,6 @@ defmodule Latch.Error.HandleNotFound do * `:invalid_handle` - the handle was malformed * `:handle_not_found` - DNS and well-known lookups returned nothing * `:ambiguous_dns` - multiple distinct DIDs at one handle - * `:unsupported_did_method` - the resolved DID method is not implemented """ defexception [:handle, :reason] diff --git a/lib/latch/error/identity_mismatch.ex b/lib/latch/error/identity_mismatch.ex index 52ea7f9..4b2ed71 100644 --- a/lib/latch/error/identity_mismatch.ex +++ b/lib/latch/error/identity_mismatch.ex @@ -1,6 +1,6 @@ defmodule Latch.Error.IdentityMismatch do @moduledoc """ - A handle reoslved to a DID, but the DID document does not read back to it, + A handle resolved to a DID, but the DID document does not read back to it, or it lacks a usable PDS endpoint. ## `reason` values diff --git a/lib/latch/error/unsupported_did_method.ex b/lib/latch/error/unsupported_did_method.ex new file mode 100644 index 0000000..28eb5ed --- /dev/null +++ b/lib/latch/error/unsupported_did_method.ex @@ -0,0 +1,14 @@ +defmodule Latch.Error.UnsupportedDIDMethod do + @moduledoc """ + Only `did:web` and `did:plc` are supported methods. + """ + + defexception [:did] + + @type t :: %__MODULE__{did: String.t()} + + @impl Exception + def message(%__MODULE__{did: did}) do + "Unsupported DID method: #{did}" + end +end diff --git a/lib/latch/identity.ex b/lib/latch/identity.ex index 338e853..f5f3057 100644 --- a/lib/latch/identity.ex +++ b/lib/latch/identity.ex @@ -17,6 +17,7 @@ defmodule Latch.Identity do alias Latch.Error.IdentityMismatch alias Latch.Error.InvalidResponse alias Latch.Error.Transport + alias Latch.Error.UnsupportedDIDMethod alias Latch.Handle alias Latch.HTTP @@ -40,17 +41,45 @@ defmodule Latch.Identity do @spec resolve_handle(Config.t(), String.t()) :: {:ok, t()} | {:error, - HandleNotFound.t() | IdentityMismatch.t() | InvalidResponse.t() | Transport.t()} + HandleNotFound.t() + | IdentityMismatch.t() + | InvalidResponse.t() + | Transport.t() + | UnsupportedDIDMethod.t()} def resolve_handle(%Config{} = config, handle) when is_binary(handle) do handle = Handle.normalize(handle) with :ok <- validate_handle(handle), {:ok, did} <- handle_to_did(config, handle), :ok <- validate_did(did, handle), - {:ok, document} <- did_to_document(config, did, handle), + {:ok, document} <- did_to_document(config, did), {:ok, parsed} <- parse_did_document(document, did, handle), :ok <- confirm_bidirectional(parsed, handle) do - {:ok, %__MODULE__{did: did, handle: handle, pds_endpoint: parsed.pds_endpoint}} + {:ok, + %__MODULE__{did: parsed.did, handle: parsed.handle, pds_endpoint: parsed.pds_endpoint}} + end + end + + @doc """ + Resolves and verifies a DID, returning the handle and PDS endpoint. + + Returns structured `Latch.Error` exceptions describing resolution, + identity-verifiction, and transport failures. + """ + @spec resolve_did(Config.t(), String.t()) :: + {:ok, t()} + | {:error, + HandleNotFound.t() + | IdentityMismatch.t() + | InvalidResponse.t() + | Transport.t() + | UnsupportedDIDMethod.t()} + def resolve_did(%Config{} = config, did) when is_binary(did) do + with :ok <- validate_did(did, nil), + {:ok, document} <- did_to_document(config, did), + {:ok, parsed} <- parse_did_document(document, did) do + {:ok, + %__MODULE__{did: parsed.did, handle: parsed.handle, pds_endpoint: parsed.pds_endpoint}} end end @@ -117,23 +146,29 @@ defmodule Latch.Identity do end end - defp did_to_document(config, "did:plc:" <> _ = did, _handle) do + defp did_to_document(config, "did:plc:" <> _ = did) do plc_directory = config.plc_directory || @plc_directory fetch_did_document(config, plc_directory <> "/" <> did) end - defp did_to_document(config, "did:web:" <> host, _handle) do + defp did_to_document(config, "did:web:" <> host) do fetch_did_document(config, "https://" <> URI.decode(host) <> "/.well-known/did.json") end - defp did_to_document(_config, _did, handle) do - {:error, %HandleNotFound{handle: handle, reason: :unsupported_did_method}} + defp did_to_document(_config, did) do + {:error, %UnsupportedDIDMethod{did: did}} end defp fetch_did_document(config, url) do HTTP.get_json(config.pool, url) end + defp parse_did_document(document, did) do + with {:error, reason} <- DIDDocument.parse(document, did) do + {:error, %IdentityMismatch{reason: reason}} + end + end + defp parse_did_document(document, did, handle) do with {:error, reason} <- DIDDocument.parse(document, did) do {:error, %IdentityMismatch{handle: handle, reason: reason}} diff --git a/test/latch/identity_test.exs b/test/latch/identity_test.exs index b67743e..73be509 100644 --- a/test/latch/identity_test.exs +++ b/test/latch/identity_test.exs @@ -3,6 +3,8 @@ defmodule Latch.IdentityTest do use Mimic alias Latch.DNS + alias Latch.Error.InvalidResponse + alias Latch.Error.UnsupportedDIDMethod alias Latch.HTTP alias Latch.Identity @@ -72,7 +74,7 @@ defmodule Latch.IdentityTest do end) expect(HTTP, :get_text, fn _pool, _url -> - {:error, %Latch.Error.InvalidResponse{reason: {:http_status, 404}}} + {:error, %InvalidResponse{reason: {:http_status, 404}}} end) reject(&HTTP.get_json/2) @@ -134,10 +136,10 @@ defmodule Latch.IdentityTest do end) expect(HTTP, :get_json, fn _pool, _url -> - {:error, %Latch.Error.InvalidResponse{reason: {:http_status, 404}}} + {:error, %InvalidResponse{reason: {:http_status, 404}}} end) - assert {:error, %Latch.Error.InvalidResponse{reason: {:http_status, 404}}} = + assert {:error, %InvalidResponse{reason: {:http_status, 404}}} = Identity.resolve_handle(config, @handle) end @@ -161,6 +163,55 @@ defmodule Latch.IdentityTest do assert {:ok, %{did: @did, handle: @handle, pds_endpoint: @pds_endpoint}} = Identity.resolve_handle(config, @handle) end + + test "unsupported did method" do + config = make_config() + did = "did:dns:potato" + + expect(DNS, :lookup_txt, fn _record -> + ["did=#{did}"] + end) + + assert {:error, %UnsupportedDIDMethod{did: ^did}} = + Identity.resolve_handle(config, @handle) + end + end + + describe "resolve_did/2" do + test "returns PDS endpoint and handle" do + config = make_config() + did_document = make_did_document(@did, @handle, @pds_endpoint) + + expect(HTTP, :get_json, fn _pool, url -> + assert url == "https://plc.directory/#{@did}" + {:ok, did_document} + end) + + assert {:ok, %{did: @did, handle: @handle, pds_endpoint: @pds_endpoint}} = + Identity.resolve_did(config, @did) + end + + test "resolves web dids" do + config = make_config() + did = "did:web:#{@handle}" + did_document = make_did_document(did, @handle, @pds_endpoint) + + expect(HTTP, :get_json, fn _pool, url -> + assert url == "https://#{@handle}/.well-known/did.json" + {:ok, did_document} + end) + + assert {:ok, %{did: ^did, handle: @handle, pds_endpoint: @pds_endpoint}} = + Identity.resolve_did(config, did) + end + + test "unsupported did method" do + config = make_config() + did = "did:dns:potato" + + assert {:error, %UnsupportedDIDMethod{did: ^did}} = + Identity.resolve_did(config, did) + end end defp make_config(overrides \\ []) do -- 2.51.2