# `Latch`

## 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

Using the built-in ETS `Store` implementation, create your module like:

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

Add `Latch` to your supervision tree, giving it a unique name and a `Latch.Store` implementation:

    children = [
      {MyApp.LatchStore, []},
      {Latch,
        name: MyApp.Latch,
        mode: :confidential,
        store: MyApp.LatchStore,
        client_id_path: "/oauth-client-metadata.json",
        redirect_uri_path: "/auth/callback",
        # Dynamically resolve the base URL at runtime, or pass a hard-coded URL
        base_url_fun: fn -> MyApp.Endpoint.url() end,
        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`. Instead of using the built-in ETS Store implementation you can create your own, implementing the `Latch.Store` behavior.

You need to set up a route to serve the client metadata, matching the configuration `/oauth-client-metadata.json`.

    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:

    {:ok,
      %{
        "uri" => "at://did:plc:abc123/app.bsky.feed.post/3k2...",
        "cid" => "bafyreid...",
        "value" => %{
          "$type" => "app.bsky.feed.post",
          "text" => "Hello atproto",
          "createdAt" => "2026-07-31T12:00:00.000Z"
        }
      }} =
      Latch.query(MyApp.Latch, did, "com.atproto.repo.getRecord",
        params: [
          repo: did,
          collection: "app.bsky.feed.post",
          rkey: "3k2..."
        ]
      )

    {:ok,
      %{
        "uri" => "at://did:plc:abc123/app.bsky.feed.post/3k5...",
        "cid" => "bafyreig..."
      }} =
      Latch.procedure(MyApp.Latch, did, "com.atproto.repo.createRecord", %{
        repo: did,
        collection: "app.bsky.feed.post",
        record: %{text: "Hello atproto", createdAt: DateTime.utc_now()}
      })

## Service auth

Latch supports service auth through PDS proxying, by passing `service` as an option to a client call, like this:

    {:ok, %{"count" => 4}} =
      Latch.query(MyApp.Latch, did, "app.bsky.notification.getUnreadCount",
        service: "did:web:api.bsky.app#bsky_appview"
      )

Alternatively, you can fetch a short-lived service auth token and pass it as a bearer token, following the documentation [here](https://docs.bsky.app/docs/advanced-guides/service-auth). Here's an example for uploading a video to Bluesky, which does not support PDS proxying.

    # grab the pds_endpoint from the user's session
    aud = "did:web:" <> URI.parse(pds_endpoint).host

    {:ok, %{"token" => jwt}} =
      Latch.query(MyApp.Latch, did, "com.atproto.server.getServiceAuth",
        params: [
          aud: aud,
          lxm: "com.atproto.repo.uploadBlob",
          exp: System.system_time(:second) + 30 * 60
        ]
      )

    Req.post("https://video.bsky.app/xrpc/app.bsky.video.uploadVideo",
      headers: [{"authorization", "Bearer " <> jwt}, {"content-type", "video/mp4"}],
      params: [did: did, name: filename],
      body: video_bytes
    )

## Errors

Public functions return `{:error, exception}` tuples and will not normally raise on errors. See `Latch.Error` for more information.

## Diagram of the OAuth flow

```mermaid
sequenceDiagram
  actor User
  participant App as Your app (Latch)
  participant PDS as PDS
  participant AS as Authorization Server

  User->>App: handle (alice.example.com)
  App->>PDS: resolve handle → DID → DID document
  App->>PDS: GET /.well-known/oauth-protected-resource
  App->>AS: GET /.well-known/oauth-authorization-server
  Note over App,AS: identity + server discovery

  App->>AS: PAR: POST pushed authorization request (DPoP)
  AS-->>App: request_uri (+ DPoP-Nonce)
  App->>User: redirect to AS authorization endpoint

  User->>AS: authenticates, approves
  AS->>User: redirect to redirect_uri (code, state, iss)
  User->>App: callback

  App->>AS: token exchange: code + PKCE verifier (DPoP, client_assertion)
  AS-->>App: access token + refresh token (bound to DPoP key)

  Note over App,PDS: session established, stored via Latch.Store

  App->>PDS: XRPC calls (access token + fresh DPoP proof)
  App->>AS: refresh when expired (DPoP)
```

## On correctness

> Postel's law: conservative in what you send, liberal in what you accept.

The library attempts to follow the spec strictly, but primarily in what the library itself does, and less strictly in what it accepts as long as it's not a security issue.

# `name`

```elixir
@type name() :: atom() | pid()
```

# `authorize`

```elixir
@spec authorize(name(), String.t()) ::
  {:ok, String.t()}
  | {:error,
     Latch.Error.HandleNotFound.t()
     | Latch.Error.IdentityMismatch.t()
     | Latch.Error.Discovery.t()
     | Latch.Error.InvalidResponse.t()
     | Latch.Error.MissingDPoPNonce.t()
     | Latch.Error.OAuth.t()
     | Latch.Error.Store.t()
     | Latch.Error.Transport.t()}
```

Begins an authorization flow for `handle`.

Resolves the handle to a DID and PDS, discovers the authorization
server, pushes the authorization request (PAR), stores the in-flight
request in the configured `Latch.Store`, and returns the URL to
redirect the browser to.

The stored request is single-use. `callback/2` consumes it.

## Examples

    iex> {:ok, _pid} = Latch.start_link(mode: :confidential, name: LatchAuthorizeExample, store: Latch.TestStore, client_id_path: "/metadata.json", redirect_uri_path: "/callback", base_url_fun: fn -> "https://example.com" end, scope: "atproto", signing_key: Jason.encode!(Latch.DPoP.generate_key()))
    iex> Latch.authorize(LatchAuthorizeExample, "not a handle")
    {:error, %Latch.Error.HandleNotFound{handle: "not a handle", reason: :invalid_handle}}

# `callback`

```elixir
@spec callback(name(), map()) ::
  {:ok, %{did: String.t(), handle: String.t()}}
  | {:error,
     Latch.Error.InvalidResponse.t()
     | Latch.Error.MissingDPoPNonce.t()
     | Latch.Error.OAuth.t()
     | Latch.Error.SecurityViolation.t()
     | Latch.Error.Store.t()
     | Latch.Error.Transport.t()}
```

Completes an authorization flow from the OAuth callback params.

Consumes a stored request. Single use, so a replayed callback fails
with `%Latch.Error.SecurityViolation{}`. Verifies the issuer, exchanges
the code, stores the session using the `Latch.Store` implementation, and
returns identity information.

# `child_spec`

Returns a child specification to start Latch under a supervisor.

## Examples

    iex> %{id: MyApp.Latch, type: :supervisor, start: {Latch, :start_link, [_opts]}} =
    ...>   Latch.child_spec(name: MyApp.Latch, store: MyApp.Store, client_id_path: "/metadata.json", redirect_uri_path: "/callback", scope: "atproto", signing_key: :test_key, mode: :confidential, base_url_fun: fn -> "https://example.com" end)

# `client_metadata`

```elixir
@spec client_metadata(name()) :: Latch.ClientMetadata.t()
```

Returns the client metadata map.

Serve it as JSON at the URL configured as `:client_id`, e.g. from a
controller: `json(conn, Latch.client_metadata(MyApp.Latch))`.

# `delete_session`

```elixir
@spec delete_session(name(), String.t()) :: :ok | {:error, Latch.Error.Store.t()}
```

Deletes the session for `did`. Call this when the user logs out.

# `procedure`

```elixir
@spec procedure(name(), String.t(), String.t(), map(), keyword()) ::
  {:ok, map()}
  | {:error,
     Latch.Error.InvalidResponse.t()
     | Latch.Error.MissingDPoPNonce.t()
     | Latch.Error.NoSession.t()
     | Latch.Error.RefreshFailed.t()
     | Latch.Error.Store.t()
     | Latch.Error.Transport.t()
     | Latch.Error.XRPC.t()}
```

Performs a procedure against the user's PDS using their DID's session.

## Options

* `service` - the service endpoint identifier when proxying through PDS, eg did:web:api.bsky.app#bsky_appview

## Examples

    Latch.procedure(MyApp.Latch, "did:plc:abc123", "com.atproto.repo.putRecord", %{
      repo: "did:plc:abc123",
      collection: "app.bsky.feed.post",
      rkey: "3k2...",
      record: %{"$type" => "app.bsky.feed.post", "text" => "Hello!"}
    })

# `query`

```elixir
@spec query(name(), String.t(), String.t(), keyword()) ::
  {:ok, map()}
  | {:error,
     Latch.Error.InvalidResponse.t()
     | Latch.Error.MissingDPoPNonce.t()
     | Latch.Error.NoSession.t()
     | Latch.Error.RefreshFailed.t()
     | Latch.Error.Store.t()
     | Latch.Error.Transport.t()
     | Latch.Error.XRPC.t()}
```

Query the user's PDS using their DID's session.

`method` is the XRPC method NSID, eg `"com.atproto.repo.getRecord"`.
`params` is passed as the query string.

Assumes the session exists, that the user of that `did` is authenticated.
If not, returns `{:error, %NoSession{}}`.

## Options

* `:service` - the service endpoint identifier when proxying through PDS, eg did:web:api.bsky.app#bsky_appview
* `:params` - params for the method, eg `params: [actor: "did:plc:bvraa6gajy4tfr3eh2sisdkr"]` resulting in
  `app.bsky.actor.getProfile?actor=did:plc:bvraa6gajy4tfr3eh2sisdkr`

## Examples

    Latch.query(MyApp.Latch, "did:plc:abc123", "com.atproto.repo.getRecord", params: [repo: "did:plc:abc123", collection: "app.bsky.feed.post", rkey: "3k2..."])

# `start_link`

Starts a Latch supervisor.

See the module documentation for the supported options.

## Examples

  iex> {:ok, pid} = Latch.start_link(name: LatchStartLinkExample, store: Latch.TestStore, mode: :confidential, client_id_path: "/metadata.json", redirect_uri_path: "/callback", base_url_fun: fn -> "https://example.com" end, scope: "atproto", signing_key: Jason.encode!(Latch.DPoP.generate_key()))
  iex> is_pid(pid)
  true

# `upload_blob`

```elixir
@spec upload_blob(name(), String.t(), binary(), String.t(), keyword()) ::
  {:ok, map()}
  | {:error,
     Latch.Error.InvalidResponse.t()
     | Latch.Error.MissingDPoPNonce.t()
     | Latch.Error.NoSession.t()
     | Latch.Error.RefreshFailed.t()
     | Latch.Error.Store.t()
     | Latch.Error.Transport.t()
     | Latch.Error.XRPC.t()}
```

Upload a blob to the user's PDS using their DID's session.

`content_type` is the blob's MIME type, eg `"image/png"`.

## Options

* `service` - the service endpoint identifier when proxying through PDS, eg did:web:api.bsky.app#bsky_appview

---

*Consult [api-reference.md](api-reference.md) for complete listing*
