diff --git a/lib/annot_at/atproto/http.ex b/lib/annot_at/atproto/http.ex index 301d4fd..8d960c5 100644 --- a/lib/annot_at/atproto/http.ex +++ b/lib/annot_at/atproto/http.ex @@ -64,6 +64,42 @@ defmodule AnnotAt.Atproto.HTTP do end end + @doc """ + Performs an HTTP request with the given method, headers, and optional JSON + body, returning the raw status, body, and response headers. + """ + @spec request(String.t(), String.t(), [{String.t(), String.t()}], map() | nil) :: + {:ok, + %{status: pos_integer(), body: binary(), headers: %{optional(binary()) => [binary()]}}} + | {:error, {:transport, term()}} + def request(method, url, headers, json_body \\ nil) do + options = [ + method: method_atom(method), + url: url, + headers: headers, + decode_body: false, + receive_timeout: @receive_timeout + ] + + options = + if json_body do + Keyword.put(options, :json, json_body) + else + options + end + + case Req.request(options) do + {:ok, %Req.Response{status: status, body: body, headers: resp_headers}} -> + {:ok, %{status: status, body: body, headers: resp_headers}} + + {:error, reason} -> + {:error, {:transport, reason}} + end + end + + defp method_atom("GET"), do: :get + defp method_atom("POST"), do: :post + defp get_body(url) when is_binary(url) do case Req.get(url, decode_body: false, receive_timeout: @receive_timeout) do {:ok, %Req.Response{status: status, body: body}} when status in 200..299 -> {:ok, body} diff --git a/lib/annot_at/atproto/oauth/login.ex b/lib/annot_at/atproto/oauth/login.ex index 76d3edf..b7b5e9d 100644 --- a/lib/annot_at/atproto/oauth/login.ex +++ b/lib/annot_at/atproto/oauth/login.ex @@ -16,6 +16,7 @@ defmodule AnnotAt.Atproto.OAuth.Login do alias AnnotAt.Atproto.OAuth.DPoP alias AnnotAt.Atproto.OAuth.Flow alias AnnotAt.Atproto.OAuth.PKCE + alias AnnotAt.Atproto.Profile require Logger @@ -151,12 +152,16 @@ defmodule AnnotAt.Atproto.OAuth.Login do end defp persist(request, session) do + profile = fetch_profile(request.handle) + Accounts.upsert_login( %{ did: session.did, handle: request.handle, pds_host: session.pds_endpoint, - handle_verified_at: DateTime.utc_now(:second) + handle_verified_at: DateTime.utc_now(:second), + display_name: profile.display_name, + avatar_url: profile.avatar_url }, %{ auth_server_issuer: session.issuer, @@ -191,4 +196,15 @@ defmodule AnnotAt.Atproto.OAuth.Login do defp callback_error(:invalid_state), do: :invalid_state defp callback_error(_reason), do: :login_failed + + defp fetch_profile(handle) do + case Profile.fetch(handle) do + {:ok, profile} -> + profile + + {:error, reason} -> + Logger.warning("failed to fetch profile for #{handle}: #{inspect(reason)}") + %{display_name: nil, avatar: nil} + end + end end diff --git a/lib/annot_at/atproto/profile.ex b/lib/annot_at/atproto/profile.ex new file mode 100644 index 0000000..f53abe8 --- /dev/null +++ b/lib/annot_at/atproto/profile.ex @@ -0,0 +1,25 @@ +defmodule AnnotAt.Atproto.Profile do + @moduledoc """ + Fetches public Bluesky profile data (display name, avatar) from the AppView. + """ + + alias AnnotAt.Atproto.HTTP + + @appview "https://public.api.bsky.app" + + @doc """ + Fetches a public profile by DID or handle. + """ + @spec fetch(String.t()) :: + {:ok, %{display_name: String.t() | nil, avatar: String.t() | nil}} + | {:error, {:http_status, pos_integer()} | {:transport, term()} | :invalid_json} + + def fetch(actor) do + url = "#{@appview}/xrpc/app.bsky.actor.getProfile?#{URI.encode_query(actor: actor)}" + + with {:ok, profile} <- HTTP.get_json(url) do + {:ok, + %{display_name: Map.get(profile, "displayName"), avatar_url: Map.get(profile, "avatar")}} + end + end +end diff --git a/lib/annot_at/atproto/xrpc.ex b/lib/annot_at/atproto/xrpc.ex new file mode 100644 index 0000000..874281b --- /dev/null +++ b/lib/annot_at/atproto/xrpc.ex @@ -0,0 +1,90 @@ +defmodule AnnotAt.Atproto.XRPC do + @moduledoc """ + DPoP-authenticated XRPC calls to a session's PDS. + + Each request carriers `Authorization: DPoP "/xrpc/" <> method <> query_string(params), + nil + ) + end + + defp request(%Session{} = session, http_method, url, body, nonce \\ nil) do + proof = + DPoP.proof(session.dpop_key, http_method, url, + nonce: nonce, + access_token: session.access_token + ) + + headers = [{"authorization", "DPoP #{session.access_token}"}, {"dpop", proof}] + + with {:ok, %{status: status, body: raw, headers: resp_headers}} <- + HTTP.request(http_method, url, headers, body), + {:ok, decoded} <- decode_json(raw) do + cond do + status in 200..299 -> + {:ok, decoded} + + needs_nonce?(status, resp_headers) -> + retry_with_nonce(session, http_method, url, body, resp_headers) + + true -> + {:error, {:xrpc_error, status, decoded}} + end + end + end + + defp retry_with_nonce(session, http_method, url, body, headers) do + case nonce_header(headers) do + nil -> {:error, :missing_dpop_nonce} + nonce -> request(session, http_method, url, body, nonce) + end + end + + defp needs_nonce?(401, headers) do + headers + |> Map.get("www-authenticate") + |> Enum.any?(&String.contains?(&1, "use_dpop_nonce")) + end + + defp needs_nonce?(_status, _headers), do: false + + defp nonce_header(headers) do + case Map.get(headers, "dpop-nonce") do + [nonce | _] -> nonce + _ -> nil + end + end + + defp decode_json(raw) do + case Jason.decode(raw) do + {:ok, json} -> {:ok, json} + _ -> {:error, :invalid_json} + end + end + + defp query_string([]), do: "" + defp query_string(params), do: "?" <> URI.encode_query(params) +end diff --git a/lib/annot_at_web/controllers/page_html/home.html.heex b/lib/annot_at_web/controllers/page_html/home.html.heex index 5a159fe..34bbdf8 100644 --- a/lib/annot_at_web/controllers/page_html/home.html.heex +++ b/lib/annot_at_web/controllers/page_html/home.html.heex @@ -3,8 +3,14 @@

annot.at

<%= if @current_scope do %> -

Signed in as {@current_scope.user.handle}

-

{@current_scope.user.did}

+ <%= if @current_scope.user.avatar_url do %> + + <% end %> +

+ {@current_scope.user.display_name || @current_scope.user.handle} +

+

{@current_scope.user.handle}

+

{@current_scope.user.did}

<.link href={~p"/logout"} method="delete" class="underline">Log out <% else %> <.link navigate={~p"/login"} class="underline">Sign in with Bluesky diff --git a/test/annot_at/atproto/oauth/login_test.exs b/test/annot_at/atproto/oauth/login_test.exs index cbc2dad..7de021f 100644 --- a/test/annot_at/atproto/oauth/login_test.exs +++ b/test/annot_at/atproto/oauth/login_test.exs @@ -10,6 +10,7 @@ defmodule AnnotAt.Atproto.OAuth.LoginTest do alias AnnotAt.Atproto.OAuth.Login alias AnnotAt.Atproto.OAuth.ServerMetadata alias AnnotAt.Atproto.OAuth.Session + alias AnnotAt.Atproto.Profile @did "did:plc:ewvi7nxzyoun6zhxrhs64oiz" @pds "https://enoki.us-east.host.bsky.network" @@ -82,10 +83,16 @@ defmodule AnnotAt.Atproto.OAuth.LoginTest do expect(Discovery, :discover, fn @pds -> {:ok, server} end) expect(Flow, :exchange_code, fn ^server, _opts -> {:ok, session} end) + expect(Profile, :fetch, fn "jola.dev" -> + {:ok, %{display_name: "Johanna", avatar_url: "https://cdn/av.jpg"}} + end) + params = %{"code" => "code-1", "state" => "state-1", "iss" => @issuer} assert {:ok, user} = Login.complete_login(params) assert @did == user.did + assert "Johanna" == user.display_name + assert "https://cdn/av.jpg" == user.avatar_url assert "access-1" == user.atproto_session.access_token refute Accounts.take_login_request("state-1") end diff --git a/test/annot_at/atproto/profile_test.exs b/test/annot_at/atproto/profile_test.exs new file mode 100644 index 0000000..565b762 --- /dev/null +++ b/test/annot_at/atproto/profile_test.exs @@ -0,0 +1,24 @@ +defmodule AnnotAt.Atproto.ProfileTest do + use ExUnit.Case, async: true + use Mimic + + alias AnnotAt.Atproto.HTTP + alias AnnotAt.Atproto.Profile + + test "fetch/1 returns the display name and avatar from the AppView" do + expect(HTTP, :get_json, fn url -> + assert "https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=did%3Aplc%3Aabc" == + url + + {:ok, %{"did" => "did:plc:abc", "displayName" => "Jola", "avatar" => "https://cdn/av.jpg"}} + end) + + assert {:ok, %{display_name: "Jola", avatar_url: "https://cdn/av.jpg"}} = + Profile.fetch("did:plc:abc") + end + + test "fetch/1 propagates an HTTP error" do + expect(HTTP, :get_json, fn _ -> {:error, {:http_status, 400}} end) + assert {:error, {:http_status, 400}} = Profile.fetch("did:plc:abc") + end +end diff --git a/test/annot_at/atproto/xrpc_test.exs b/test/annot_at/atproto/xrpc_test.exs new file mode 100644 index 0000000..0ce5090 --- /dev/null +++ b/test/annot_at/atproto/xrpc_test.exs @@ -0,0 +1,94 @@ +defmodule AnnotAt.Atproto.XRPCTest do + use ExUnit.Case, async: true + use Mimic + + alias AnnotAt.Atproto.HTTP + alias AnnotAt.Atproto.OAuth.Session + alias AnnotAt.Atproto.XRPC + + @pds "https://shaggymane.us-west.host.bsky.network" + + setup do + jwk = + "../../support/fixtures/atproto/es256_jwk.json" + |> Path.expand(__DIR__) + |> File.read!() + |> Jason.decode!() + |> JOSE.JWK.from() + + session = %Session{ + did: "did:plc:abc", + access_token: "access-1", + refresh_token: "refresh-1", + dpop_key: jwk, + scope: "atproto", + issuer: "https://bsky.social", + pds_endpoint: @pds, + expires_at: ~U[2026-01-01 00:00:00Z] + } + + %{session: session} + end + + test "query/3 sends a DPoP-authenticated GET with ath and returns the body", %{session: session} do + expect(HTTP, :request, fn "GET", url, headers, nil -> + assert "#{@pds}/xrpc/app.bsky.actor.getProfile?actor=did%3Aplc%3Aabc" == url + assert {"authorization", "DPoP access-1"} in headers + + {"dpop", proof} = List.keyfind(headers, "dpop", 0) + %JOSE.JWT{fields: claims} = JOSE.JWT.peek_payload(proof) + assert claims["ath"] + + {:ok, %{status: 200, body: ~s({"displayName":"Jola"}), headers: %{}}} + end) + + assert {:ok, %{"displayName" => "Jola"}} = + XRPC.query(session, "app.bsky.actor.getProfile", actor: "did:plc:abc") + end + + test "query/3 retries with the PDS nonce", %{session: session} do + expect(HTTP, :request, fn "GET", _url, _headers, nil -> + {:ok, + %{ + status: 401, + body: ~s({}), + headers: %{ + "www-authenticate" => ["DPoP error=\"use_dpop_nonce\""], + "dpop-nonce" => ["nonce-1"] + } + }} + end) + + expect(HTTP, :request, fn "GET", _url, headers, nil -> + {"dpop", proof} = List.keyfind(headers, "dpop", 0) + %JOSE.JWT{fields: claims} = JOSE.JWT.peek_payload(proof) + assert "nonce-1" == claims["nonce"] + + {:ok, %{status: 200, body: ~s({"ok":true}), headers: %{}}} + end) + + assert {:ok, %{"ok" => true}} = XRPC.query(session, "app.bsky.actor.getProfile") + end + + test "query/3 surfaces an XRPC error", %{session: session} do + expect(HTTP, :request, fn "GET", _url, _headers, nil -> + {:ok, %{status: 400, body: ~s({"error":"InvalidRequest"}), headers: %{}}} + end) + + assert {:error, {:xrpc_error, 400, %{"error" => "InvalidRequest"}}} = + XRPC.query(session, "app.bsky.actor.getProfile") + end + + test "query/3 returns :missing_dpop_nonce when the 401 has no nonce header", %{session: session} do + expect(HTTP, :request, fn "GET", _url, _headers, nil -> + {:ok, + %{ + status: 401, + body: ~s({}), + headers: %{"www-authenticate" => ["DPoP error=\"use_dpop_nonce\""]} + }} + end) + + assert {:error, :missing_dpop_nonce} = XRPC.query(session, "app.bsky.actor.getProfile") + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index e3323c8..c27a0b0 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,6 +1,7 @@ Mimic.copy(AnnotAt.Atproto.DNS) Mimic.copy(AnnotAt.Atproto.HTTP) Mimic.copy(AnnotAt.Atproto.Identity) +Mimic.copy(AnnotAt.Atproto.Profile) Mimic.copy(AnnotAt.Atproto.OAuth.Discovery) Mimic.copy(AnnotAt.Atproto.OAuth.Flow) Mimic.copy(AnnotAt.Atproto.OAuth.Login)