Latch: an idiomatic Elixir OAuth atproto library

Or how I stopped worrying and learned to love the ETS


I've previously written about Latch and I wanted to give a little update. This side quest ended up taking quite a lot of time to complete, but now I'm finally seeing the light at the end of the tunnel.

The biggest thing was ergonomics of having to implement a Store, with six callbacks and some unfortunately annoying logic. Maybe it's just me, but implementing callbacks always feels awkward. Can't quite keep in my head who's calling who... So I added a built-in Store implementation using ETS. The implementation ended up not being very bad at all, if you ignore the scary but necessary __using__ defmacro. Implementation here.

The core of the implementation is this, all six callbacks:

 def put_request(ref, state, request, ttl) do
    sweep_after_timestamp = System.monotonic_time(:second) + ttl
    :ets.insert(ref, {state, request, sweep_after_timestamp})
    :ok
  end

  def take_request(ref, state) do
    case :ets.take(ref, state) do
      [] ->
        {:error, :not_found}

      [{^state, request, sweep_after_timestamp} | _] ->
        if sweep_after_timestamp > System.monotonic_time(:second) do
          {:ok, request}
        else
          {:error, :not_found}
        end
    end
  end

  def fetch_session(ref, did) do
    case :ets.lookup(ref, did) do
      [] -> {:error, :not_found}
      [{^did, session} | _] -> {:ok, session}
    end
  end

  def put_session(ref, did, session) do
    :ets.insert(ref, {did, session})
    :ok
  end

  def delete_session(ref, did) do
    :ets.delete(ref, did)
    :ok
  end

  def update_session(ref, did, fun) do
    with {:ok, session} <- fetch_session(ref, did) do
      with {:ok, session} <- fun.(session) do
        :ok = put_session(ref, did, session)
        {:ok, session}
      end
    end
  rescue
    error ->
      Logger.warning("Latch.Store.ETS implementation raised with: #{inspect(error)}")
      {:error, :backend_error}
  end

And the rest of it is just plumbing for the GenServer that owns it. Got some pretty serious integration tests in place too https://tangled.org/jola.dev/latch/blob/edf5badb2f6c54408538566ef217176433524ade/test/latch/store/ets_test.exs

A sample:

test "lifecycle" do
  pid = start_latch()

  # AUTHORIZE SECTION

  identity = %Identity{did: @did, handle: @handle, pds_endpoint: @pds}
  server = server()
  request_uri = "urn:ietf:params:oauth:request_uri:request"

  expect(Identity, :resolve_handle, fn _handle -> {:ok, identity} end)
  expect(Discovery, :discover, fn _pds, _opts -> {:ok, server} end)

  expect(Flow, :par, fn _config, _server, opts ->
    send(self(), {:state, opts[:state]})
    {:ok, request_uri}
  end)

  {:ok, _redirect_url} = Latch.authorize(pid, @handle)

  assert_receive {:state, state}

  # CALLBACK SECTION

  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: DateTime.add(DateTime.utc_now(), 5, :minute)
  }

  expect(Flow, :exchange_code, fn _config, _opts ->
    {:ok, session}
  end)

  assert {:ok, %{did: @did, handle: @handle}} =
            Latch.callback(
              pid,
              %{
                "state" => state,
                "iss" => @issuer,
                "code" => "auth-code"
              }
            )

  # CLIENT SECTION

  expect(XRPC, :query, fn _config, _session, _method, _opts ->
    {:ok, %{"did" => @did}}
  end)

  assert {:ok, %{"did" => @did}} =
            Latch.query(pid, @did, "app.bsky.actor.getProfile", actor: @did)

  assert :ok = Latch.delete_session(pid, @did)

  reject(&XRPC.query/4)

  assert {:error, %Latch.Error.NoSession{}} =
            Latch.query(pid, @did, "app.bsky.actor.getProfile", actor: @did)
end

Reaping the benefits of using the "Finch pattern" for library design here, we can easily mock and integration test even with async: true.

The one less ideal quirk is the part where you have to define your own ETS store module with use.

defmodule MyApp.LatchStore do
  use Latch.Store.ETS
end

The practical reason why it's designed this way is that the implementor's store needs to be bound to their "instance", and use can neatly do that. The downside is that now there can only be a single instance of MyApp.LatchStore, we're not passing a dynamic name. So we can't have two instances running concurrently. I don't expect that really matters much to the user of the library, and it's not really a problem for the library either. Yet. I might revisit this later but for now, this is fine.

Next up is writing some better documentation and an integration guide.


Log in to leave a note.