From 1d2ba324ec584851466fa622cb4d9e8616059b9e Mon Sep 17 00:00:00 2001 From: Johanna Larsson Date: Sun, 26 Jul 2026 09:51:28 +0100 Subject: [PATCH] Fix documentation Uses the Finch pattern for sharing the main module doc with the README.md. Tried to start encoding a guide for how to use this, but will probably want a separate "guide". --- README.md | 81 +++++++++++++++++++++++++++++++++++++++++++++-- lib/latch.ex | 68 ++++----------------------------------- lib/latch/http.ex | 2 +- 3 files changed, 86 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index f1de54d..40863e4 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,85 @@ # Latch -atproto OAuth library attempting to follow the specification strictly, while also following Elixir library guidelines. The goal is for the library to be easy to use and not get in your way, but fully flexible. +atproto OAuth and client library attempting to follow the specification strictly, while also following Elixir library guidelines. The goal is for the library to be easy to use and not get in your way, but fully flexible. Use it to build atproto based apps where users can log in with their atproto accounts, from existing services like Bluesky, Blacksky or Eurosky. -This is a pretty extensive introduction to atproto OAuth [Beyond the Statusphere: Part 2, ATProto OAuth, the TLDR](https://leaflet.pub/77df80c7-ec7e-4728-afa9-e367d99adb97). The core of this code originates from [annot.at](https://annot.at). + + +## Get started + +### What you need + +1. If you're going to share your application online, you're going to need a public HTTPS URL for it. Authorization servers need to access your server over HTTPS. For local development, use `mode: :localhost`. Alternatively, use a service like https://cimd-service.fly.dev/ to host your client metadata for you. +2. Most backend applications will want to run in `mode: :confidential` in which case they need an ES256 (P-256) private JWK, encoded as JSON. Generate one and store it somewhere safe, it's a secret, treat it like a password. + mix run -e '{_, jwk} = JOSE.JWK.to_map(JOSE.JWK.generate_key({:ec, "P-256"})); IO.puts(Jason.encode!(jwk))' + + export ATPROTO_CLIENT_PRIVATE_JWK='{"kty":"EC",...}' +3. Expose the client metadata and an OAuth callback URL on your site, that's `:client_id` and `:redirect_uri`. +4. A `Latch.Store` implementation. You can write your own, or use the built-in ETS implementation. + +### Setting it up + +Add `Latch` to your supervision tree, giving it a unique name +and a `Latch.Store` implementation: + + children = [ + {Latch, + name: MyApp.Latch, + mode: :confidential, + store: MyApp.LatchStore, + client_id: "https://myapp.example/oauth-client-metadata.json", + redirect_uri: "https://myapp.example/auth/callback", + scope: "atproto", + signing_key: System.fetch_env!("ATPROTO_CLIENT_PRIVATE_JWK")} + ] + +`signing_key` is the JWK from step 2 above. Optional keys: `:client_name`, `:client_uri` and `request_ttl`. + +Set up a route to serve the client metadata. + + def client_metadata(conn, _params) do + json(conn, Latch.client_metadata(MyApp.Latch)) + end + +### Login flow + +1. `authorize/2` resolves the handle, pushes the authorization request, + and returns the URL to redirect the browser to. + + {:ok, url} = Latch.authorize(MyApp.Latch, "alice.bsky.social") + +2. The user authorizes, and their authorization server redirects back + to your `redirect_uri`. +3. `callback/2` validates the callback params, exchanges the code, and + stores the session for you. Returns identity information: + + {:ok, %{did: did, handle: handle}} = Latch.callback(MyApp.Latch, conn.params) + +The session is stored keyed by `did` — that `did` is all you need for +authenticated calls. When a user logs out, call `delete_session`: + + :ok = Latch.delete_session(MyApp.Latch, did) + +### Make authenticated requests + +Calls go to the user's PDS, and access tokens are refreshed automatically: + + Latch.query(MyApp.Latch, did, "com.atproto.repo.getRecord", + repo: did, + collection: "app.bsky.feed.post", + rkey: "3k2...") + + Latch.procedure(MyApp.Latch, did, "com.atproto.repo.createRecord", %{ + repo: did, + collection: "app.bsky.feed.post", + record: %{text: "Hello atproto", createdAt: DateTime.utc_now()} + }) + +## Errors + +Public functions return `{:error, exception}` tuples and will not normally +raise on errors. See `Latch.Error` for more information. + + ## Installation diff --git a/lib/latch.ex b/lib/latch.ex index 4bc9eb9..d0e49cf 100644 --- a/lib/latch.ex +++ b/lib/latch.ex @@ -1,66 +1,9 @@ defmodule Latch do - @moduledoc """ - A library for building atproto OAuth integrations with a low-level - client runtime. - - Latch implements [atproto OAuth](https://atproto.com/specs/oauth), including: - * identity resolution - * server discovery - * pushed authorization requests (PAR) - * proof key for code exchange (PKCE) - * demonstrating proof of possession (DPoP) with server-issued nonces - * token exchange - * refresh - * authenticated XRPC calls to a user's PDS - - ## Get started - - Add `Latch` to your supervision tree, giving it a unique name - and a `Latch.Store` implementation: - - children = [ - {Latch, - name: MyApp.Latch, - store: MyApp.LatchStore, - client_id: "https://myapp.example/oauth-client-metadata.json", - redirect_uri: "https://myapp.example/auth/callback", - scope: "atproto", - signing_key: "key"}, - mode: :confidential - ] - - Optional keys: `:client_name`, `:client_uri` and `request_ttl`. - - ## Login flow - - 1. `authorize/2` resolves the handle, pushes the authorization request, - and returns the URL to redirect the browser to. - 2. The user authorizes, their authorization server redirects back to your - `redirect_uri`. - 3. `callback/2` validates the callback params, exchanges the code, and - stores the session for you using your `Latch.Store` implementation. - Returns identity information for the user. - - When a user logs out, call `delete_session` to clear their session. - - ## Authenticated requests - - `query/4`, `procedure/4`, and `upload_blob/4` make XRPC calls to a user's - PDS, where their data is stored, using DPoP under the hood. The session lives - in your datastore, defined through your `Latch.Store` module. Latch uses - that to store and rotate access tokens for the client requests. - - Latch.query(MyApp.Latch, "did:plc:abc123", "com.atproto.repo.getRecord", - repo: "did:plc:abc123", - collection: "app.bsky.feed.post", - rkey: "3k2..." - ) - - ## Errors - - Public functions return `{:error, exception}` tuples and will not normally - raise on errors. See `Latch.Error` for more information. - """ + @external_resource "README.md" + @moduledoc "README.md" + |> File.read!() + |> String.split("") + |> Enum.fetch!(1) use Supervisor @@ -248,6 +191,7 @@ defmodule Latch do end @doc """ + Deletes the session for `did`. Call this when the user logs out. """ @spec delete_session(name(), String.t()) :: :ok | {:error, StoreError.t()} def delete_session(name, did) do diff --git a/lib/latch/http.ex b/lib/latch/http.ex index 9772b90..2a4e842 100644 --- a/lib/latch/http.ex +++ b/lib/latch/http.ex @@ -4,7 +4,7 @@ defmodule Latch.HTTP do Fetches raw bodies and decodes JSON explicity, so behavior does not depend on server-provided content types (DID documents are sometimes served as - `application/did+ld+json). + `application/did+ld+json`). """ alias Latch.Error.InvalidResponse -- 2.51.2