From 596daf9d9a6d6259d2dff06ac3317727030c35fa Mon Sep 17 00:00:00 2001 From: Ashlynne Mitchell Date: Sat, 28 Mar 2026 12:55:02 -0700 Subject: [PATCH] feat: service auth xrpc client --- CHANGELOG.md | 1 + lib/atex/xrpc/service_auth_client.ex | 79 ++++++++++++++++++++++++++++ mix.exs | 3 +- 3 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 lib/atex/xrpc/service_auth_client.ex diff --git a/CHANGELOG.md b/CHANGELOG.md index 82a14ef..ce05e51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ and this project adheres to validation and validation if passed the name of a module using `deflexicon`. - `deflexicon` now emits `content_type/0` functions (on `Input` submodules for typed JSON bodies, otherwise on the root module) for procedures. +- `Atex.XRPC.ServiceAuthClient` module for making requests to other atproto services using a service auth token. ### Fixed diff --git a/lib/atex/xrpc/service_auth_client.ex b/lib/atex/xrpc/service_auth_client.ex new file mode 100644 index 0000000..56e3cf1 --- /dev/null +++ b/lib/atex/xrpc/service_auth_client.ex @@ -0,0 +1,79 @@ +defmodule Atex.XRPC.ServiceAuthClient do + @moduledoc """ + An XRPC client that uses a inter-service auth JWT to interact with another + service on a user's behalf. See `Atex.ServiceAuth` and + [`com.atproto.server.getServiceAuth`](https://github.com/bluesky-social/atproto/blob/main/lexicons/com/atproto/server/getServiceAuth.json) + for more information. + + ## Usage + + client = Atex.XRPC.ServiceAuthClient.new("") + + {:ok, response, _} = Atex.XRPC.get(client, "com.example.authenticatedXRPC") + """ + + alias Atex.{DID.Document, IdentityResolver, XRPC} + + use TypedStruct + @behaviour Atex.XRPC.Client + + typedstruct do + field :token, String.t(), enforce: true + end + + @doc """ + Create a new `Atex.XRPC.ServiceAuthClient` from a service auth JWT. + + The JWT is stored as-is; no validation is performed at construction time. + Endpoint resolution and token use happen on the first (and only valid) call + to `get/3` or `post/3`. + + ## Examples + + iex> Atex.XRPC.ServiceAuthClient.new("eyJ...") + %Atex.XRPC.ServiceAuthClient{token: "eyJ..."} + """ + @spec new(String.t()) :: t() + def new(token) when is_binary(token), do: %__MODULE__{token: token} + + @impl true + def get(%__MODULE__{} = client, resource, opts \\ []) do + with {:ok, endpoint} <- resolve_endpoint(client) do + request(client, opts ++ [method: :get, url: XRPC.url(endpoint, resource)]) + end + end + + @impl true + def post(%__MODULE__{} = client, resource, opts \\ []) do + with {:ok, endpoint} <- resolve_endpoint(client) do + request(client, opts ++ [method: :post, url: XRPC.url(endpoint, resource)]) + end + end + + @spec request(t(), keyword()) :: {:ok, Req.Response.t(), t()} | {:error, any(), t()} + defp request(client, opts) do + req = opts |> Req.new() |> put_auth(client.token) + + case Req.request(req) do + {:ok, response} -> {:ok, response, client} + {:error, reason} -> {:error, reason, client} + end + end + + @spec resolve_endpoint(t()) :: {:ok, String.t()} | {:error, any()} + defp resolve_endpoint(%__MODULE__{token: token}) do + %{fields: %{"aud" => aud}} = JOSE.JWT.peek(token) + + with {:ok, identity} <- IdentityResolver.resolve(aud), + endpoint when not is_nil(endpoint) <- Document.get_pds_endpoint(identity.document) do + {:ok, endpoint} + else + nil -> {:error, :no_pds_endpoint} + err -> err + end + end + + @spec put_auth(Req.Request.t(), String.t()) :: Req.Request.t() + defp put_auth(request, token), + do: Req.Request.put_header(request, "authorization", "Bearer #{token}") +end diff --git a/mix.exs b/mix.exs index 20c788b..9e2176b 100644 --- a/mix.exs +++ b/mix.exs @@ -69,12 +69,13 @@ defmodule Atex.MixProject do source_ref: "v#{@version}", formatters: ["html"], groups_for_modules: [ - "Data types": [Atex.AtURI, Atex.DID, Atex.Handle, Atex.NSID, Atex.TID], + "Data types": [Atex.AtURI, ~r/^Atex\.DID/, Atex.Handle, Atex.NSID, Atex.TID], XRPC: ~r/^Atex\.XRPC/, PLC: [Atex.PLC], OAuth: [Atex.Config.OAuth, ~r/^Atex\.OAuth/], Identity: [Atex.Config.IdentityResolver, ~r/^Atex\.IdentityResolver/], Lexicons: ~r/^Atex\.Lexicon/, + "Service Auth": ~r/^Atex\.ServiceAuth/, "Implementation details": [Atex.Base32Sortable, Atex.Peri] ] ] -- 2.51.2