diff --git a/.credo.exs b/.credo.exs index 5f82308..7c5dc77 100644 --- a/.credo.exs +++ b/.credo.exs @@ -140,7 +140,6 @@ {Credo.Check.Refactor.FunctionArity, []}, {Credo.Check.Refactor.LongQuoteBlocks, []}, {Credo.Check.Refactor.MapJoin, []}, - {Credo.Check.Refactor.MatchInCondition, []}, {Credo.Check.Refactor.NegatedConditionsInUnless, []}, {Credo.Check.Refactor.NegatedConditionsWithElse, []}, {Credo.Check.Refactor.Nesting, [max_nesting: 3]}, @@ -198,6 +197,7 @@ {Credo.Check.Refactor.FilterReject, []}, {Credo.Check.Refactor.IoPuts, []}, {Credo.Check.Refactor.MapMap, []}, + {Credo.Check.Refactor.MatchInCondition, []}, {Credo.Check.Refactor.ModuleDependencies, []}, {Credo.Check.Refactor.NegatedIsNil, []}, {Credo.Check.Refactor.RejectFilter, []}, @@ -205,7 +205,7 @@ {Credo.Check.Warning.LazyLogging, []}, {Credo.Check.Warning.LeakyEnvironment, []}, {Credo.Check.Warning.MapGetUnsafePass, []}, - {Credo.Check.Warning.UnsafeToAtom, []} + {Credo.Check.Warning.UnsafeToAtom, []}, # {Credo.Check.Refactor.MapInto, []}, diff --git a/lib/latch/did_document.ex b/lib/latch/did_document.ex index 4f175a3..02cd2d1 100644 --- a/lib/latch/did_document.ex +++ b/lib/latch/did_document.ex @@ -45,46 +45,51 @@ defmodule Latch.DIDDocument do end defp claimed_handle(document) do - result = - document - |> Map.get("alsoKnownAs", []) - |> Enum.find_value(fn - "at://" <> handle = uri when is_binary(uri) -> - if Handle.valid?(handle) do - handle - else - nil - end - - _ -> - nil - end) - - case result do - nil -> {:error, :invalid_handle} - handle -> {:ok, handle} + case Map.get(document, "alsoKnownAs", []) do + aliases when is_list(aliases) -> + if handle = Enum.find_value(aliases, &handle_from_alias/1) do + {:ok, handle} + else + {:error, :invalid_handle} + end + + _ -> + {:error, :invalid_handle} end end + defp handle_from_alias("at://" <> handle) do + if Handle.valid?(handle), do: handle + end + + defp handle_from_alias(_alias), do: nil + defp pds_endpoint(document) do - result = - document - |> Map.get("service", []) - |> Enum.find(fn service -> - String.ends_with?(to_string(service["id"]), "#atproto_pds") and - Map.get(service, "type") == @pds_type - end) - - case result do - nil -> {:error, :no_pds} - service -> validate_endpoint(service["serviceEndpoint"]) + case Map.get(document, "service", []) do + services when is_list(services) -> + if service = Enum.find(services, &pds_service?/1) do + validate_endpoint(Map.get(service, "serviceEndpoint")) + else + {:error, :no_pds} + end + + _ -> + {:error, :no_pds} end end + defp pds_service?(%{"id" => id, "type" => @pds_type}) when is_binary(id) do + String.ends_with?(id, "#atproto_pds") + end + + defp pds_service?(_service), do: false + + # We allow both HTTPS and HTTP to support local development, but in the future + # we may lock the HTTP option behind a local dev flag and be stricter. defp validate_endpoint(url) when is_binary(url) do case URI.parse(url) do %URI{scheme: scheme, host: host, path: nil, query: nil, userinfo: nil} - when scheme in ["http", "https"] and is_binary(host) and host != "" -> + when scheme in ["https", "http"] and is_binary(host) and host != "" -> {:ok, url} _ -> diff --git a/lib/latch/discovery.ex b/lib/latch/discovery.ex index ad35c53..7eb23bc 100644 --- a/lib/latch/discovery.ex +++ b/lib/latch/discovery.ex @@ -38,7 +38,7 @@ defmodule Latch.Discovery do case Map.get(resource, "authorization_servers") do # There has to be exactly one issuer according to the spec. [issuer] when is_binary(issuer) -> - {:ok, issuer} + validate_authorization_server(issuer, pds_endpoint) _ -> {:error, @@ -50,6 +50,28 @@ defmodule Latch.Discovery do end end + defp validate_authorization_server(issuer, pds_endpoint) do + case URI.parse(issuer) do + %URI{ + scheme: scheme, + host: host, + path: nil, + query: nil, + fragment: nil, + userinfo: nil + } + when scheme in ["http", "https"] and is_binary(host) and host != "" -> + {:ok, issuer} + + _ -> + {:error, + %DiscoveryError{ + pds_endpoint: pds_endpoint, + reason: :invalid_authorization_server + }} + end + end + defp verify_resource(%{"resource" => resource}, resource), do: :ok defp verify_resource(_resource, pds_endpoint) do diff --git a/lib/latch/flow.ex b/lib/latch/flow.ex index d9c2b0c..0b00d00 100644 --- a/lib/latch/flow.ex +++ b/lib/latch/flow.ex @@ -177,7 +177,8 @@ defmodule Latch.Flow do ] end - with {:ok, body} <- + with :ok <- verify_refresh_issuer(server.issuer, session.issuer), + {:ok, body} <- dpop_request(server.token_endpoint, build_form, session.dpop_key), {:ok, tokens} <- parse_token_response(body), :ok <- verify_sub(tokens.sub, session.did) do @@ -260,6 +261,12 @@ defmodule Latch.Flow do defp maybe_put(map, _key, nil), do: map defp maybe_put(map, key, value), do: Keyword.put(map, key, value) + defp verify_refresh_issuer(issuer, issuer), do: :ok + + defp verify_refresh_issuer(_discovered_issuer, _session_issuer) do + {:error, %SecurityViolation{reason: :issuer_mismatch}} + end + defp verify_sub(sub, sub), do: :ok defp verify_sub(_sub, _expected), do: {:error, %SecurityViolation{reason: :did_mismatch}} diff --git a/lib/latch/server_metadata.ex b/lib/latch/server_metadata.ex index be8f281..3207f3a 100644 --- a/lib/latch/server_metadata.ex +++ b/lib/latch/server_metadata.ex @@ -38,9 +38,9 @@ defmodule Latch.ServerMetadata do @spec parse(map()) :: {:ok, t()} | {:error, {:missing | :invalid, String.t()}} def parse(metadata) when is_map(metadata) do with :ok <- origin_url(metadata, "issuer"), - :ok <- https_url(metadata, "authorization_endpoint"), - :ok <- https_url(metadata, "token_endpoint"), - :ok <- https_url(metadata, "pushed_authorization_request_endpoint"), + :ok <- http_url(metadata, "authorization_endpoint"), + :ok <- http_url(metadata, "token_endpoint"), + :ok <- http_url(metadata, "pushed_authorization_request_endpoint"), :ok <- member(metadata, "response_types_supported", "code"), :ok <- member(metadata, "grant_types_supported", "authorization_code"), :ok <- member(metadata, "grant_types_supported", "refresh_token"), @@ -65,7 +65,7 @@ defmodule Latch.ServerMetadata do end defp origin_url(metadata, field) do - with :ok <- https_url(metadata, field) do + with :ok <- http_url(metadata, field) do url = Map.get(metadata, field) case URI.parse(url) do @@ -78,15 +78,19 @@ defmodule Latch.ServerMetadata do end end - defp https_url(metadata, field) do + defp http_url(metadata, field) do case Map.get(metadata, field) do nil -> {:error, {:missing, field}} value when is_binary(value) -> case URI.parse(value) do - %URI{scheme: "https", host: host} when is_binary(host) and host != "" -> :ok - _ -> {:error, {:invalid, field}} + %URI{scheme: scheme, host: host} + when scheme in ["http", "https"] and is_binary(host) and host != "" -> + :ok + + _ -> + {:error, {:invalid, field}} end _ -> diff --git a/lib/latch/token_response.ex b/lib/latch/token_response.ex index 60148ea..080b9c2 100644 --- a/lib/latch/token_response.ex +++ b/lib/latch/token_response.ex @@ -37,7 +37,7 @@ defmodule Latch.TokenResponse do def parse(response) when is_map(response) do with :ok <- string(response, "access_token"), :ok <- string(response, "refresh_token"), - :ok <- string(response, "scope"), + :ok <- scope(response), :ok <- token_type(response), :ok <- expires_in(response), :ok <- did(response, "sub") do @@ -60,6 +60,16 @@ defmodule Latch.TokenResponse do end end + defp scope(response) do + with :ok <- string(response, "scope") do + if "atproto" in String.split(Map.fetch!(response, "scope")) do + :ok + else + {:error, {:invalid, "scope"}} + end + end + end + defp token_type(response) do case Map.get(response, "token_type") do nil -> diff --git a/mix.exs b/mix.exs index fcbd0ac..3af1cc2 100644 --- a/mix.exs +++ b/mix.exs @@ -8,7 +8,8 @@ defmodule Latch.MixProject do elixir: "~> 1.20", start_permanent: Mix.env() == :prod, deps: deps(), - aliases: aliases() + aliases: aliases(), + elixirc_paths: elixirc_paths(Mix.env()) ] end @@ -25,13 +26,18 @@ defmodule Latch.MixProject do ] end + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + # Run "mix help deps" to learn about dependencies. defp deps do [ {:req, "~> 0.5"}, {:jose, "~> 1.11"}, {:jason, "~> 1.2"}, - {:credo, "~> 1.7", only: [:dev, :test]} + {:credo, "~> 1.7", only: [:dev, :test]}, + {:mimic, "~> 2.3", only: :test} ] end diff --git a/mix.lock b/mix.lock index 374a1e4..8908305 100644 --- a/mix.lock +++ b/mix.lock @@ -3,11 +3,13 @@ "credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "finch": {:hex, :finch, "0.23.0", "e3f9287ac25a8832f848b144c2b57346aac65b205e2e0629a52adfe6507fd837", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "80e58d3f936f57e3fdf404f83a3642897ae6d9fb642934e46da4d8fe761b99d5"}, - "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "ham": {:hex, :ham, "0.3.2", "02ae195f49970ef667faf9d01bc454fb80909a83d6c775bcac724ca567aeb7b3", [:mix], [], "hexpm", "b71cc684c0e5a3d32b5f94b186770551509e93a9ae44ca1c1a313700f2f6a69a"}, + "hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"}, "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, "jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, - "mint": {:hex, :mint, "1.9.0", "d6f534c2a3e98b2a8cc749b4796eb77e9e3af79a76f96e4c74035a827de0d318", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "007154c7d8c43916aed3c93afd1f11aebbaa9c5ff4b7ba55ebe0d17ee0296042"}, + "mimic": {:hex, :mimic, "2.3.0", "88b1d13c285e57df6ea57204317bb56e49e7329668006cdcb80a9aafc73a9616", [:mix], [{:ham, "~> 0.3", [hex: :ham, repo: "hexpm", optional: false]}], "hexpm", "52771f23689398c5d41c7d05e91c2c28e10df273b784f40ca8b02e35e46850d3"}, + "mint": {:hex, :mint, "1.9.1", "3bc120b743ed2e99ad920910f2613e9faebabb2257731b0e2ea4d8ccd9eceede", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "831101bd560b086316fab5f7adb21a4f3455717d8e4bc8368b052e09aa9163e0"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "req": {:hex, :req, "0.6.2", "b9b2024f35bcf60a92cc8cad2eaaf9d4e7aace463ff74be1afe5986830184413", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [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", "cc9cd30a2ddd04989929b887178e1610c940456d962c6c3a52df6146d2eef9bf"}, diff --git a/test/latch/client_test.exs b/test/latch/client_test.exs new file mode 100644 index 0000000..f726a6b --- /dev/null +++ b/test/latch/client_test.exs @@ -0,0 +1,112 @@ +defmodule Latch.ClientTest do + use ExUnit.Case, async: true + use Mimic + + alias Latch.Client + alias Latch.Config + alias Latch.Discovery + alias Latch.Error.XRPC, as: XRPCError + alias Latch.Flow + alias Latch.ServerMetadata + alias Latch.Session + alias Latch.XRPC + + @did "did:plc:bvraa6gajy4tfr3eh2sisdkr" + @pds "https://pds.example.com" + @issuer "https://issuer.example.com" + @client_id "https://client.example.com/oauth-client-metadata.json" + @redirect_uri "https://client.example.com/oauth/callback" + + describe "query/3" do + test "refreshes an expired session before making an XRPC query" do + config = %Config{ + store: Latch.TestStore, + client_id: @client_id, + redirect_uri: @redirect_uri, + scope: "atproto", + signing_key: nil + } + + stale_session = session("stale-access-token", ~U[2020-01-01 00:00:00Z]) + refreshed_session = session("fresh-access-token", ~U[2030-01-01 00:00:00Z]) + server = server() + + :ok = Latch.TestStore.put_session(@did, stale_session) + + expect(Discovery, :discover, fn @pds -> {:ok, server} end) + + expect(Flow, :refresh, fn ^server, ^stale_session, opts -> + assert opts[:client_id] == config.client_id + assert opts[:client_jwk] == config.signing_key + {:ok, refreshed_session} + end) + + expect(XRPC, :query, fn ^refreshed_session, "app.bsky.actor.getProfile", actor: @did -> + {:ok, %{"did" => @did}} + end) + + assert {:ok, %{"did" => @did}} = + Client.query(config, @did, "app.bsky.actor.getProfile", actor: @did) + + assert {:ok, ^refreshed_session} = Latch.TestStore.fetch_session(@did) + end + + test "refreshes and retries once when an XRPC request returns 401" do + config = %Config{ + store: Latch.TestStore, + client_id: @client_id, + redirect_uri: @redirect_uri, + scope: "atproto", + signing_key: nil + } + + stale_session = session("stale-access-token", ~U[2030-01-01 00:00:00Z]) + refreshed_session = session("fresh-access-token", ~U[2030-01-01 00:00:00Z]) + server = server() + + :ok = Latch.TestStore.put_session(@did, stale_session) + + expect(XRPC, :query, 2, fn + ^stale_session, "app.bsky.actor.getProfile", actor: @did -> + {:error, %XRPCError{status: 401, body: %{}}} + + ^refreshed_session, "app.bsky.actor.getProfile", actor: @did -> + {:ok, %{"did" => @did}} + end) + + expect(Discovery, :discover, fn @pds -> {:ok, server} end) + + expect(Flow, :refresh, fn ^server, ^stale_session, _opts -> + {:ok, refreshed_session} + end) + + assert {:ok, %{"did" => @did}} = + Client.query(config, @did, "app.bsky.actor.getProfile", actor: @did) + + assert {:ok, ^refreshed_session} = Latch.TestStore.fetch_session(@did) + end + end + + defp session(access_token, expires_at) do + %Session{ + did: @did, + access_token: access_token, + refresh_token: "refresh-token", + dpop_key: nil, + scope: "atproto", + issuer: @issuer, + pds_endpoint: @pds, + expires_at: expires_at + } + end + + defp server do + %ServerMetadata{ + issuer: @issuer, + authorization_endpoint: @issuer <> "/oauth/authorize", + token_endpoint: @issuer <> "/oauth/token", + par_endpoint: @issuer <> "/oauth/par", + scopes_supported: ["atproto"] + } + end +end diff --git a/test/latch/did_document_test.exs b/test/latch/did_document_test.exs new file mode 100644 index 0000000..e34ccfd --- /dev/null +++ b/test/latch/did_document_test.exs @@ -0,0 +1,36 @@ +defmodule Latch.DidDocumentTest do + use ExUnit.Case, async: true + + alias Latch.DIDDocument + + @did "did:plc:bvraa6gajy4tfr3eh2sisdkr" + + test "parses a did document with an HTTPS PDS endpoint" do + assert {:ok, document} = DIDDocument.parse(document(), @did) + + assert document.did == @did + assert document.handle == "alice.example.com" + assert document.pds_endpoint == "https://pds.example.com" + end + + test "returns errors for malformed collection fields" do + assert {:error, :invalid_handle} = + DIDDocument.parse(%{document() | "alsoKnownAs" => "at://alice.example.com"}, @did) + + assert {:error, :no_pds} = DIDDocument.parse(%{document() | "service" => %{}}, @did) + end + + defp document(endpoint \\ "https://pds.example.com") do + %{ + "id" => @did, + "alsoKnownAs" => ["at://alice.example.com"], + "service" => [ + %{ + "id" => "#atproto_pds", + "type" => "AtprotoPersonalDataServer", + "serviceEndpoint" => endpoint + } + ] + } + end +end diff --git a/test/latch/flow_test.exs b/test/latch/flow_test.exs new file mode 100644 index 0000000..d83d224 --- /dev/null +++ b/test/latch/flow_test.exs @@ -0,0 +1,138 @@ +defmodule Latch.FlowTest do + use ExUnit.Case, async: true + use Mimic + + alias Latch.Error.SecurityViolation + alias Latch.Flow + alias Latch.HTTP + alias Latch.ServerMetadata + alias Latch.Session + + describe "exchange_code/1" do + test "exchanges an authorization code for a session" do + did = "did:plc:bvraa6gajy4tfr3eh2sisdkr" + access_token = "access-token" + refresh_token = "refresh-token" + + client_jwk = JOSE.JWK.generate_key({:ec, "P-256"}) + dpop_key = JOSE.JWK.generate_key({:ec, "P-256"}) + + expect(HTTP, :post_form, fn url, form, headers -> + assert url == "https://issuer.example.com/oauth/token" + assert form[:grant_type] == "authorization_code" + assert form[:code] == "authorization-code" + assert form[:code_verifier] == "pkce-verifier" + assert is_binary(form[:client_assertion]) + assert {"dpop", proof} = List.keyfind(headers, "dpop", 0) + assert is_binary(proof) + + {:ok, + %{ + status: 200, + headers: %{}, + body: + Jason.encode!(%{ + "access_token" => access_token, + "refresh_token" => refresh_token, + "token_type" => "DPoP", + "expires_in" => 3600, + "scope" => "atproto", + "sub" => did + }) + }} + end) + + assert {:ok, session} = + Flow.exchange_code( + client_id: "https://client.example.com/oauth-client-metadata.json", + client_jwk: client_jwk, + redirect_uri: "https://client.example.com/oauth/callback", + code: "authorization-code", + code_verifier: "pkce-verifier", + dpop_key: dpop_key, + expected_did: did, + pds_endpoint: "https://pds.example.com", + issuer: "https://issuer.example.com", + token_endpoint: "https://issuer.example.com/oauth/token", + now: ~U[2026-01-01 00:00:00Z] + ) + + assert session.did == did + assert session.access_token == access_token + assert session.refresh_token == refresh_token + assert session.dpop_key == dpop_key + assert session.expires_at == ~U[2026-01-01 01:00:00Z] + end + end + + describe "par/2" do + test "creates a pushed authorization request" do + client_jwk = JOSE.JWK.generate_key({:ec, "P-256"}) + dpop_key = JOSE.JWK.generate_key({:ec, "P-256"}) + + server = %ServerMetadata{ + issuer: "https://issuer.example.com", + authorization_endpoint: "https://issuer.example.com/oauth/authorize", + token_endpoint: "https://issuer.example.com/oauth/token", + par_endpoint: "https://issuer.example.com/oauth/par", + scopes_supported: ["atproto"] + } + + expect(HTTP, :post_form, fn url, form, headers -> + assert url == "https://issuer.example.com/oauth/par" + assert form[:response_type] == "code" + assert form[:state] == "state" + assert form[:code_challenge] == "pkce-challenge" + assert {"dpop", proof} = List.keyfind(headers, "dpop", 0) + assert is_binary(proof) + + {:ok, + %{ + status: 201, + headers: %{}, + body: ~s({"request_uri":"urn:ietf:params:oauth:request_uri:request"}) + }} + end) + + assert {:ok, "urn:ietf:params:oauth:request_uri:request"} = + Flow.par(server, + client_id: "https://client.example.com/oauth-client-metadata.json", + client_jwk: client_jwk, + redirect_uri: "https://client.example.com/oauth/callback", + scope: "atproto", + state: "state", + code_challenge: "pkce-challenge", + dpop_key: dpop_key, + login_hint: "alice.example.com" + ) + end + end + + describe "refresh/3" do + test "rejects a refresh when discovery returns a different issuer" do + reject(HTTP, :post_form, 3) + + server = %ServerMetadata{ + issuer: "https://other.example.com", + authorization_endpoint: "https://other.example.com/oauth/authorize", + token_endpoint: "https://other.example.com/oauth/token", + par_endpoint: "https://other.example.com/oauth/par", + scopes_supported: ["atproto"] + } + + session = %Session{ + did: "did:plc:bvraa6gajy4tfr3eh2sisdkr", + access_token: "access-token", + refresh_token: "refresh-token", + dpop_key: nil, + scope: "atproto", + issuer: "https://issuer.example.com", + pds_endpoint: "https://pds.example.com", + expires_at: ~U[2026-01-01 00:00:00Z] + } + + assert {:error, %SecurityViolation{reason: :issuer_mismatch}} = + Flow.refresh(server, session, client_id: "client-id", client_jwk: nil) + end + end +end diff --git a/test/latch/token_response_test.exs b/test/latch/token_response_test.exs new file mode 100644 index 0000000..0a1a485 --- /dev/null +++ b/test/latch/token_response_test.exs @@ -0,0 +1,34 @@ +defmodule Latch.TokenResponseTest do + use ExUnit.Case, async: true + + alias Latch.TokenResponse + + @access_token "access_token" + @refresh_token "refresh-token" + @scope "atproto transition:generic" + @sub "did:plc:bvraa6gajy4tfr3eh2sisdkr" + + @response %{ + "access_token" => @access_token, + "refresh_token" => @refresh_token, + "token_type" => "DPoP", + "expires_in" => 3600, + "scope" => @scope, + "sub" => @sub + } + + test "parses a valid DPoP token response with the atproto scope" do + assert {:ok, token} = TokenResponse.parse(@response) + + assert token.access_token == @access_token + assert token.refresh_token == @refresh_token + assert token.scope == @scope + assert token.sub == @sub + end + + test "rejects a token response without the atproto scope" do + response = %{@response | "scope" => "transition:generic"} + + assert {:error, {:invalid, "scope"}} = TokenResponse.parse(response) + end +end diff --git a/test/latch_test.exs b/test/latch_test.exs index fdc1bff..685b3ca 100644 --- a/test/latch_test.exs +++ b/test/latch_test.exs @@ -1,4 +1,187 @@ defmodule LatchTest do use ExUnit.Case, async: true + use Mimic doctest Latch + + alias Latch.Config + alias Latch.Discovery + alias Latch.Flow + alias Latch.Identity + alias Latch.Request + alias Latch.ServerMetadata + alias Latch.Session + + @did "did:plc:bvraa6gajy4tfr3eh2sisdkr" + @handle "alice.example.com" + @pds "https://pds.example.com" + @issuer "https://issuer.example.com" + @client_id "https://client.example.com/oauth-client-metadata.json" + @redirect_uri "https://client.example.com/oauth/callback" + + describe "authorize/2" do + test "resolves identity, creates PAR, stores the request, and returns the redirect URL" do + request_uri = "urn:ietf:params:oauth:request_uri:request" + + config = %Config{ + store: Latch.TestStore, + client_id: @client_id, + redirect_uri: @redirect_uri, + scope: "atproto", + signing_key: nil + } + + identity = %Identity{did: @did, handle: @handle, pds_endpoint: @pds} + server = server() + + expect(Identity, :resolve_handle, fn @handle -> {:ok, identity} end) + expect(Discovery, :discover, fn @pds -> {:ok, server} end) + + expect(Flow, :par, fn ^server, opts -> + assert opts[:client_id] == config.client_id + assert opts[:redirect_uri] == config.redirect_uri + assert opts[:scope] == "atproto" + assert opts[:login_hint] == @handle + assert is_binary(opts[:state]) + assert is_binary(opts[:code_challenge]) + assert %JOSE.JWK{} = opts[:dpop_key] + + {:ok, request_uri} + end) + + assert {:ok, redirect_url} = Latch.authorize(@handle, config) + + assert URI.parse(redirect_url).path == "/oauth/authorize" + + query = + redirect_url + |> URI.parse() + |> Map.fetch!(:query) + |> URI.decode_query() + + assert %{ + "client_id" => @client_id, + "request_uri" => ^request_uri + } = query + + assert_receive {:request_stored, state, %Request{} = request, 600} + assert request.state == state + assert request.did == @did + assert request.handle == @handle + assert request.pds_endpoint == @pds + assert request.issuer == @issuer + assert request.token_endpoint == @issuer <> "/oauth/token" + assert is_binary(request.pkce_verifier) + assert %JOSE.JWK{} = request.dpop_key + end + end + + describe "callback/2" do + test "consumes the request, verifies the issuer, and exchanges the authorization code" do + state = "state-123" + code = "authorization-code" + dpop_key = JOSE.JWK.generate_key({:ec, "P-256"}) + + config = %Config{ + store: Latch.TestStore, + client_id: @client_id, + redirect_uri: @redirect_uri, + scope: "atproto", + signing_key: nil + } + + request = %Request{ + state: state, + did: @did, + handle: @handle, + pds_endpoint: @pds, + issuer: @issuer, + token_endpoint: @issuer <> "/oauth/token", + pkce_verifier: "pkce-verifier", + dpop_key: dpop_key + } + + session = %Session{ + did: @did, + access_token: "access-token", + refresh_token: "refresh-token", + dpop_key: dpop_key, + scope: "atproto", + issuer: @issuer, + pds_endpoint: @pds, + expires_at: ~U[2026-01-01 01:00:00Z] + } + + :ok = Latch.TestStore.put_request(state, request, 600) + + expect(Flow, :exchange_code, fn opts -> + assert opts[:code] == code + assert opts[:code_verifier] == "pkce-verifier" + assert opts[:dpop_key] == dpop_key + assert opts[:expected_did] == @did + assert opts[:issuer] == @issuer + assert opts[:token_endpoint] == @issuer <> "/oauth/token" + + {:ok, session} + end) + + assert {:ok, ^session} = + Latch.callback( + %{ + "state" => state, + "iss" => @issuer, + "code" => code + }, + config + ) + + assert {:error, %Latch.Error.SecurityViolation{reason: :state_mismatch}} = + Latch.callback(%{"state" => state, "iss" => @issuer, "code" => code}, config) + end + end + + describe "refresh/2" do + test "rediscovers the authorization server and refreshes the session" do + config = %Config{ + store: Latch.TestStore, + client_id: @client_id, + redirect_uri: @redirect_uri, + scope: "atproto", + signing_key: nil + } + + session = %Session{ + did: @did, + access_token: "access-token", + refresh_token: "refresh-token", + dpop_key: nil, + scope: "atproto", + issuer: @issuer, + pds_endpoint: @pds, + expires_at: ~U[2026-01-01 00:00:00Z] + } + + refreshed_session = %{session | access_token: "refreshed-access-token"} + server = server() + + expect(Discovery, :discover, fn @pds -> {:ok, server} end) + + expect(Flow, :refresh, fn ^server, ^session, opts -> + assert opts[:client_id] == config.client_id + assert opts[:client_jwk] == config.signing_key + {:ok, refreshed_session} + end) + + assert {:ok, ^refreshed_session} = Latch.refresh(session, config) + end + end + + defp server do + %ServerMetadata{ + issuer: @issuer, + authorization_endpoint: @issuer <> "/oauth/authorize", + token_endpoint: @issuer <> "/oauth/token", + par_endpoint: @issuer <> "/oauth/par", + scopes_supported: ["atproto"] + } + end end diff --git a/test/support/test_store.ex b/test/support/test_store.ex new file mode 100644 index 0000000..92886ca --- /dev/null +++ b/test/support/test_store.ex @@ -0,0 +1,60 @@ +defmodule Latch.TestStore do + @moduledoc false + + @behaviour Latch.Store + + alias Latch.Request + alias Latch.Session + + @requests_key {__MODULE__, :requests} + @sessions_key {__MODULE__, :sessions} + + def put_request(state, %Request{} = request, ttl_seconds) do + requests = Process.get(@requests_key, %{}) + Process.put(@requests_key, Map.put(requests, state, request)) + send(self(), {:request_stored, state, request, ttl_seconds}) + :ok + end + + def take_request(state) do + requests = Process.get(@requests_key, %{}) + + case Map.pop(requests, state) do + {nil, _requests} -> + {:error, :not_found} + + {%Request{} = request, remaining_requests} -> + Process.put(@requests_key, remaining_requests) + {:ok, request} + end + end + + def delete_expired_requests(_max_age_seconds), do: :ok + + def fetch_session(did) do + case Process.get(@sessions_key, %{}) do + %{^did => %Session{} = session} -> {:ok, session} + _ -> {:error, :not_found} + end + end + + def put_session(did, %Session{} = session) do + sessions = Process.get(@sessions_key, %{}) + Process.put(@sessions_key, Map.put(sessions, did, session)) + :ok + end + + def delete_session(did) do + sessions = Process.get(@sessions_key, %{}) + Process.put(@sessions_key, Map.delete(sessions, did)) + :ok + end + + def update_session(did, fun) do + with {:ok, session} <- fetch_session(did), + {:ok, %Session{} = updated_session} <- fun.(session), + :ok <- put_session(did, updated_session) do + {:ok, updated_session} + end + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index 869559e..127cf4d 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1 +1,7 @@ +Mimic.copy(Latch.Discovery) +Mimic.copy(Latch.Flow) +Mimic.copy(Latch.HTTP) +Mimic.copy(Latch.Identity) +Mimic.copy(Latch.XRPC) + ExUnit.start()