Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477defmodule Latch do @external_resource "README.md" @moduledoc "README.md" |> File.read!() |> String.split("<!-- MDOC !-->") |> Enum.fetch!(1)
use Supervisor
alias Latch.ClientMetadata alias Latch.Config alias Latch.Discovery alias Latch.DPoP alias Latch.Error.Discovery, as: DiscoveryError alias Latch.Error.HandleNotFound alias Latch.Error.IdentityMismatch alias Latch.Error.InvalidResponse alias Latch.Error.MissingDPoPNonce alias Latch.Error.NoSession alias Latch.Error.OAuth alias Latch.Error.RefreshFailed alias Latch.Error.SecurityViolation alias Latch.Error.Store, as: StoreError alias Latch.Error.Transport alias Latch.Error.XRPC, as: XRPCError alias Latch.Flow alias Latch.Identity alias Latch.PKCE alias Latch.Pool alias Latch.Request alias Latch.Session alias Latch.SessionInfo
@default_request_ttl 600
@type name :: atom() | pid()
@doc """ 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 """ def start_link(opts) do name = Keyword.fetch!(opts, :name) config = Config.build!(opts)
Supervisor.start_link(__MODULE__, %{name: name, config: config}, name: name) end
@doc """ 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) """ def child_spec(opts) do name = Keyword.fetch!(opts, :name)
%{ id: name, type: :supervisor, start: {__MODULE__, :start_link, [opts]} } end
@impl Supervisor def init(%{name: name, config: config}) do :persistent_term.put({__MODULE__, self()}, config) :persistent_term.put({__MODULE__, name}, config)
pool = if Pool.start_pool?(config) do {Finch, name: Pool.name(config.name), pools: %{default: Pool.default_pool_opts()}} end
children = Enum.reject( [ {Latch.NonceCache, config: config, name: name}, pool, {Task.Supervisor, name: :"#{name}_task_supervisor"} ], &is_nil/1 )
Supervisor.init( children, strategy: :one_for_one ) end
@doc """ 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))`. """ @spec client_metadata(name()) :: ClientMetadata.t() def client_metadata(name) do %Config{} = config = config(name)
ClientMetadata.build( client_id: Config.client_id(config), redirect_uris: [Config.redirect_uri(config)], scope: config.scope, jwk: config.signing_key, client_name: config.client_name, client_uri: config.client_uri, mode: config.mode ) end
@doc """ 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}} """ @spec authorize(name(), String.t()) :: {:ok, String.t()} | {:error, HandleNotFound.t() | IdentityMismatch.t() | DiscoveryError.t() | InvalidResponse.t() | MissingDPoPNonce.t() | OAuth.t() | StoreError.t() | Transport.t()} def authorize(name, handle) do %Config{} = config = config(name)
verifier = PKCE.generate_verifier() dpop_key = DPoP.generate_key() state = Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)
with {:ok, identity} <- Identity.resolve_handle(config, handle), {:ok, server} <- Discovery.discover(config, identity.pds_endpoint, allow_http: Config.localhost?(config) ), {:ok, request_uri} <- Flow.par(config, server, client_id: Config.client_id(config), client_jwk: config.signing_key, redirect_uri: Config.redirect_uri(config), scope: config.scope, state: state, code_challenge: PKCE.challenge(verifier), dpop_key: dpop_key, login_hint: identity.handle ), request = %Request{ state: state, did: identity.did, handle: identity.handle, pds_endpoint: identity.pds_endpoint, issuer: server.issuer, token_endpoint: server.token_endpoint, pkce_verifier: verifier, dpop_key: dpop_key }, :ok <- store_request(config, request) do {:ok, Flow.authorization_url(server, Config.client_id(config), request_uri)} end end
@doc """ 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. """ @spec callback(name(), map()) :: {:ok, %{did: String.t(), handle: String.t()}} | {:error, InvalidResponse.t() | MissingDPoPNonce.t() | OAuth.t() | SecurityViolation.t() | StoreError.t() | Transport.t()} def callback(name, %{"state" => state} = params) when is_binary(state) do %Config{} = config = config(name)
with {:ok, request} <- take_request(config, state) do complete_callback(params, request, config) end end
def callback(_name, _params) do {:error, %InvalidResponse{reason: :unexpected_response}} 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 %Config{} = config = config(name)
with {:error, reason} <- config.store.delete_session(did) do {:error, %StoreError{action: :delete_session, did: did, reason: reason}} end end
@doc """ 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` * `:http` - opts to pass through to the HTTP client * `:receive_timeout` - milliseconds before timing out the request
## Examples
Latch.query(MyApp.Latch, "did:plc:abc123", "com.atproto.repo.getRecord", params: [repo: "did:plc:abc123", collection: "app.bsky.feed.post", rkey: "3k2..."]) """ @spec query(name(), String.t(), String.t(), keyword()) :: {:ok, map()} | {:error, InvalidResponse.t() | MissingDPoPNonce.t() | NoSession.t() | RefreshFailed.t() | StoreError.t() | Transport.t() | XRPCError.t()} def query(name, did, method, opts \\ []) do %Config{} = config = config(name)
Latch.Client.query(config, did, method, opts) end
@doc """ 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 * `:http` - opts to pass through to the HTTP client * `:receive_timeout` - milliseconds before timing out the request
## 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!"} }) """ @spec procedure(name(), String.t(), String.t(), map(), keyword()) :: {:ok, map()} | {:error, InvalidResponse.t() | MissingDPoPNonce.t() | NoSession.t() | RefreshFailed.t() | StoreError.t() | Transport.t() | XRPCError.t()} def procedure(name, did, method, body, opts \\ []) do %Config{} = config = config(name)
Latch.Client.procedure(config, did, method, body, opts) end
@doc """ 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 * `:http` - opts to pass through to the HTTP client * `:receive_timeout` - milliseconds before timing out the request """ @spec upload_blob(name(), String.t(), binary(), String.t(), keyword()) :: {:ok, map()} | {:error, InvalidResponse.t() | MissingDPoPNonce.t() | NoSession.t() | RefreshFailed.t() | StoreError.t() | Transport.t() | XRPCError.t()} def upload_blob(name, did, bytes, content_type, opts \\ []) do %Config{} = config = config(name)
Latch.Client.upload_blob(config, did, bytes, content_type, opts) end
@doc """ Resolves and verifies a handle, returning its DID and PDS endpoint.
Returns structured `Latch.Error` exceptions describing resolution, identity-verifiction, and transport failures. """ @spec resolve_handle(name(), String.t()) :: {:ok, Identity.t()} | {:error, HandleNotFound.t() | IdentityMismatch.t() | InvalidResponse.t() | Transport.t() | UnsupportedDIDMethod.t()} def resolve_handle(name, handle) do %Config{} = config = config(name)
Identity.resolve_handle(config, handle) end
@doc """ Resolves and verifies a DID, returning the handle and PDS endpoint.
Returns structured `Latch.Error` exceptions describing resolution, identity-verifiction, and transport failures. """ @spec resolve_did(name(), String.t()) :: {:ok, Identity.t()} | {:error, HandleNotFound.t() | IdentityMismatch.t() | InvalidResponse.t() | Transport.t() | UnsupportedDIDMethod.t()} def resolve_did(name, did) do %Config{} = config = config(name)
Identity.resolve_did(config, did) end
@doc """ Looks up the DID in the session store and returns info on the session if it exists. The session may be expired. Does not return `access_token` nor `refresh_token` as they should be considered internal to Latch. """ @spec session_info(name(), String.t()) :: {:ok, SessionInfo.t()} | {:error, StoreError.t() | NoSession.t()} def session_info(name, did) do %Config{} = config = config(name)
case config.store.fetch_session(did) do {:ok, %Session{} = session} -> {:ok, SessionInfo.from_session(session)}
{:error, :not_found} -> {:error, %NoSession{did: did}}
{:error, reason} -> {:error, %StoreError{action: :fetch_session, did: did, reason: reason}} end end
defp complete_callback( %{"error" => error, "iss" => issuer} = params, request, _config ) when is_binary(error) do with :ok <- verify_issuer(request, issuer) do {:error, %OAuth{ error: error, description: Map.get(params, "error_description"), error_uri: Map.get(params, "error_uri") }} end end
defp complete_callback( %{"code" => code, "iss" => issuer}, %Request{} = request, config ) when is_binary(code) do with :ok <- verify_issuer(request, issuer), {:ok, session} <- Flow.exchange_code(config, client_id: Config.client_id(config), client_jwk: config.signing_key, redirect_uri: Config.redirect_uri(config), code: code, code_verifier: request.pkce_verifier, dpop_key: request.dpop_key, expected_did: request.did, pds_endpoint: request.pds_endpoint, issuer: request.issuer, token_endpoint: request.token_endpoint ), :ok <- store_session(config, session) do {:ok, %{did: session.did, handle: request.handle, pds_endpoint: session.pds_endpoint}} end end
defp complete_callback(_params, _request, _config) do {:error, %InvalidResponse{reason: :unexpected_response}} end
defp store_request(config, request) do with {:error, reason} <- config.store.put_request( request.state, request, config.request_ttl || @default_request_ttl ) do {:error, %StoreError{ action: :put_request, did: request.did, reason: reason }} end end
defp store_session(config, session) do with {:error, reason} <- config.store.put_session(session.did, session) do {:error, %StoreError{action: :put_session, did: session.did, reason: reason}} end end
defp take_request(config, state) do case config.store.take_request(state) do {:ok, %Request{} = request} -> {:ok, request}
{:error, :not_found} -> {:error, %SecurityViolation{reason: :state_mismatch}}
{:error, reason} -> {:error, %StoreError{action: :take_request, did: nil, reason: reason}} end end
defp verify_issuer(%Request{issuer: issuer}, issuer), do: :ok
defp verify_issuer(_request, _issuer) do {:error, %SecurityViolation{reason: :issuer_mismatch}} end
defp config(name) do :persistent_term.get({__MODULE__, name}) endend