···11-# The directory Mix will write compiled artifacts to.
21/_build/
33-44-# If you run "mix test --cover", coverage assets end up here.
52/cover/
66-77-# The directory Mix downloads your dependencies sources to.
83/deps/
99-1010-# Where third-party dependencies like ExDoc output generated docs.
114/doc/
1212-1313-# If the VM crashes, it generates a dump, let's ignore it too.
145erl_crash.dump
1515-1616-# Also ignore archive artifacts (built via "mix archive.build").
176*.ez
1818-1919-# Ignore package tarball (built via "mix hex.build").
207atex-*.tar
2121-2222-# Temporary files, for example, from tests.
238/tmp/
99+/priv/dets/
24102511.envrc
2626-.direnv1212+.direnv
1313+.vscode/
1414+.elixir_ls
1515+lexicons
1616+secrets
1717+.DS_Store
1818+CLAUDE.md
1919+tmp
2020+temp
.vscode/settings.json
This is a binary file and will not be displayed.
+40
AGENTS.md
···11+# Agent Guidelines for atex
22+33+## Commands
44+55+- **Test**: `mix test` (all), `mix test test/path/to/file_test.exs` (single
66+ file), `mix test test/path/to/file_test.exs:42` (single test at line)
77+- **Format**: `mix format` (auto-formats all code)
88+- **Lint**: `mix credo` (static analysis, TODO checks disabled)
99+- **Compile**: `mix compile`
1010+- **Docs**: `mix docs`
1111+1212+## Code Style
1313+1414+- **Imports**: Use `alias` for modules (e.g.,
1515+ `alias Atex.Config.OAuth, as: Config`), import macros sparingly
1616+- **Formatting**: Elixir 1.18+, auto-formatted via `.formatter.exs` with
1717+ `import_deps: [:typedstruct, :peri, :plug]`
1818+- **Naming**: snake_case for functions/variables, PascalCase for modules,
1919+ descriptive names (e.g., `authorization_metadata`, not `auth_meta`)
2020+- **Types**: Use `@type` and `@spec` for all public functions; leverage
2121+ TypedStruct for structs
2222+- **Moduledocs**: All public modules need `@moduledoc`, public functions need
2323+ `@doc` with examples
2424+ - When writing lists in documentation, use `-` as the list character.
2525+- **Error Handling**: Return `{:ok, result}` or `{:error, reason}` tuples; use
2626+ pattern matching in case statements
2727+- **Pattern Matching**: Prefer pattern matching over conditionals; use guards
2828+ when appropriate
2929+- **Macros**: Use `deflexicon` macro for lexicon definitions; use `defschema`
3030+ (from Peri) for validation schemas
3131+- **Tests**: Async by default (`use ExUnit.Case, async: true`), use doctests
3232+ where applicable
3333+- **Dependencies**: Core deps include Peri (validation), Req (HTTP), JOSE
3434+ (JWT/OAuth), TypedStruct (structs)
3535+3636+## Important Notes
3737+3838+- **DO NOT modify** `lib/atproto/**/` - autogenerated from official AT Protocol
3939+ lexicons
4040+- **Update CHANGELOG.md** when adding features, changes, or fixes
+112-2
CHANGELOG.md
···66and this project adheres to
77[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8899+<!-- ## [Unreleased] -->
1010+1111+## [0.7.0] - 2026-01-07
1212+1313+### Breaking Changes
1414+1515+- `Atex.OAuth.Plug` now raises `Atex.OAuth.Error` exceptions instead of handling
1616+ error situations internally. Applications should implement `Plug.ErrorHandler`
1717+ to catch and gracefully handle them.
1818+- `Atex.OAuth.Plug` now saves only the user's DID in the session instead of the
1919+ entire OAuth session object. Applications must use `Atex.OAuth.SessionStore`
2020+ to manage OAuth sessions.
2121+- `Atex.XRPC.OAuthClient` has been overhauled to use `Atex.OAuth.SessionStore`
2222+ for retrieving and managing OAuth sessions, making it easier to use with not
2323+ needing to manually keep a Plug session in sync.
2424+2525+### Added
2626+2727+- `Atex.OAuth.SessionStore` behaviour and `Atex.OAuth.Session` struct for
2828+ managing OAuth sessions with pluggable storage backends.
2929+ - `Atex.OAuth.SessionStore.ETS` - in-memory session store implementation.
3030+ - `Atex.OAuth.SessionStore.DETS` - persistent disk-based session store
3131+ implementation.
3232+- `Atex.OAuth.Plug` now requires a `:callback` option that is a MFA tuple
3333+ (Module, Function, Args), denoting a callback function to be invoked by after
3434+ a successful OAuth login. See [the OAuth example](./examples/oauth.ex) for a
3535+ simple usage of this.
3636+- `Atex.OAuth.Permission` module for creating
3737+ [AT Protocol permission](https://atproto.com/specs/permission) strings for
3838+ OAuth.
3939+- `Atex.OAuth.Error` exception module for OAuth flow errors. Contains both a
4040+ human-readable `message` string and a machine-readable `reason` atom for error
4141+ handling.
4242+- `Atex.OAuth.Cache` module provides TTL caching for OAuth authorization server
4343+ metadata with a 1-hour default TTL to reduce load on third-party PDSs.
4444+- `Atex.OAuth.get_authorization_server/2` and
4545+ `Atex.OAuth.get_authorization_server_metadata/2` now support an optional
4646+ `fresh` parameter to bypass the cache when needed.
4747+4848+### Changed
4949+5050+- `mix atex.lexicons` now adds `@moduledoc false` to generated modules to stop
5151+ them from automatically cluttering documentation.
5252+- `Atex.IdentityResolver.Cache.ETS` now uses ConCache instead of ETS directly,
5353+ with a 1-hour TTL for cached identity information.
5454+5555+## [0.6.0] - 2025-11-25
5656+5757+### Breaking Changes
5858+5959+- `deflexicon` now converts all def names to be in snake_case instead of the
6060+ casing as written the lexicon.
6161+6262+### Added
6363+6464+- `deflexicon` now emits structs for records, objects, queries, and procedures.
6565+- `Atex.XRPC.get/3` and `Atex.XRPC.post/3` now support having a lexicon struct
6666+ as the second argument instead of the method's name, making it easier to have
6767+ properly checked XRPC calls.
6868+- Add pre-transpiled modules for the core `com.atproto` lexicons.
6969+7070+## [0.5.0] - 2025-10-11
7171+7272+### Breaking Changes
7373+7474+- Remove `Atex.HTTP` and associated modules as the abstraction caused a bit too
7575+ much complexities for how early atex is. It may come back in the future as
7676+ something more fleshed out once we're more stable.
7777+- Rename `Atex.XRPC.Client` to `Atex.XRPC.LoginClient`
7878+7979+### Added
8080+8181+- `Atex.OAuth` module with utilites for handling some OAuth functionality.
8282+- `Atex.OAuth.Plug` module (if Plug is loaded) which provides a basic but
8383+ complete OAuth flow, including storing the tokens in `Plug.Session`.
8484+- `Atex.XRPC.Client` behaviour for implementing custom client variants.
8585+- `Atex.XRPC` now supports using different client implementations.
8686+- `Atex.XRPC.OAuthClient` to make XRPC calls on the behalf of a user who has
8787+ authenticated with ATProto OAuth.
8888+8989+## [0.4.0] - 2025-08-27
9090+9191+### Added
9292+9393+- `Atex.Lexicon` module that provides the `deflexicon` macro, taking in a JSON
9494+ Lexicon definition and converts it into a series of schemas for each
9595+ definition within it.
9696+- `mix atex.lexicons` for converting lexicon JSON files into modules using
9797+ `deflexicon` easily.
9898+9999+## [0.3.0] - 2025-06-29
100100+101101+### Changed
102102+103103+- `Atex.XRPC.Adapter` renamed to `Atex.HTTP.Adapter`.
104104+105105+### Added
106106+107107+- `Atex.HTTP` module that delegates to the currently configured adapter.
108108+- `Atex.HTTP.Response` struct to be returned by `Atex.HTTP.Adapter`.
109109+- `Atex.IdentityResolver` module for resolving and validating an identity,
110110+ either by DID or a handle.
111111+ - Also has a pluggable cache (with a default ETS implementation) for keeping
112112+ some data locally.
113113+9114## [0.2.0] - 2025-06-09
101151111-## Added
116116+### Added
1211713118- `Atex.TID` module for manipulating ATProto TIDs.
14119- `Atex.Base32Sortable` module for encoding/decoding numbers as
···1912420125Initial release.
211262222-[unreleased]: https://github.com/cometsh/atex/compare/v0.2.0...HEAD
127127+[unreleased]: https://github.com/cometsh/atex/compare/v0.7.0...HEAD
128128+[0.7.0]: https://github.com/cometsh/atex/releases/tag/v0.7.0
129129+[0.6.0]: https://github.com/cometsh/atex/releases/tag/v0.6.0
130130+[0.5.0]: https://github.com/cometsh/atex/releases/tag/v0.5.0
131131+[0.4.0]: https://github.com/cometsh/atex/releases/tag/v0.4.0
132132+[0.3.0]: https://github.com/cometsh/atex/releases/tag/v0.3.0
23133[0.2.0]: https://github.com/cometsh/atex/releases/tag/v0.2.0
24134[0.1.0]: https://github.com/cometsh/atex/releases/tag/v0.1.0
+8-6
README.md
···88- [x] `at://` parsing and struct
99- [x] TID codecs
1010- [x] XRPC client
1111-- [ ] DID & handle resolution service with a cache
1212-- [ ] Structs with validation for the common lexicons
1313- - [ ] Probably codegen for doing this with other lexicons
1414-- [ ] Extended XRPC client with support for validated inputs/outputs
1515-- [ ] Oauth stuff
1111+- [x] DID & handle resolution service with a cache
1212+- [x] Macro for converting a Lexicon definition into a runtime-validation schema
1313+ - [x] Codegen to convert a directory of lexicons
1414+- [x] Oauth stuff
1515+- [x] Extended XRPC client with support for validated inputs/outputs
1616+- [ ] Proper MST & CAR handling things
1717+- [ ] Pre-transpiled libraries for popular lexicons
16181719## Installation
1820···2123```elixir
2224def deps do
2325 [
2424- {:atex, "~> 0.1"}
2626+ {:atex, "~> 0.7"}
2527 ]
2628end
2729```
···11+defmodule Atex.Application do
22+ @moduledoc false
33+44+ use Application
55+66+ def start(_type, _args) do
77+ children = [
88+ Atex.IdentityResolver.Cache,
99+ Atex.OAuth.Cache,
1010+ Atex.OAuth.SessionStore,
1111+ {Mutex, name: Atex.SessionMutex}
1212+ ]
1313+1414+ Supervisor.start_link(children, strategy: :one_for_one)
1515+ end
1616+end
+159
lib/atex/aturi.ex
···11+defmodule Atex.AtURI do
22+ @moduledoc """
33+ Struct and helper functions for manipulating `at://` URIs, which identify
44+ specific records within the AT Protocol.
55+66+ ATProto spec: https://atproto.com/specs/at-uri-scheme
77+88+ This module only supports the restricted URI syntax used for the Lexicon
99+ `at-uri` type, with no support for query strings or fragments. If/when the
1010+ full syntax gets widespread use, this module will expand to accomodate them.
1111+1212+ Both URIs using DIDs and handles ("example.com") are supported.
1313+ """
1414+1515+ use TypedStruct
1616+1717+ @did ~S"did:(?:plc|web):[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]"
1818+ @handle ~S"(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?"
1919+ @nsid ~S"[a-zA-Z](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?:\.[a-zA-Z](?:[a-zA-Z0-9]{0,62})?)"
2020+2121+ @authority "(?<authority>(?:#{@did})|(?:#{@handle}))"
2222+ @collection "(?<collection>#{@nsid})"
2323+ @rkey "(?<rkey>[a-zA-Z0-9.-_:~]{1,512})"
2424+2525+ @re ~r"^at://#{@authority}(?:/#{@collection}(?:/#{@rkey})?)?$"
2626+2727+ typedstruct do
2828+ field :authority, String.t(), enforce: true
2929+ field :collection, String.t() | nil
3030+ field :rkey, String.t() | nil
3131+ end
3232+3333+ @doc """
3434+ Create a new AtURI struct from a string by matching it against the regex.
3535+3636+ Returns `{:ok, aturi}` if a valid `at://` URI is given, otherwise it will return `:error`.
3737+3838+ ## Examples
3939+4040+ iex> Atex.AtURI.new("at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26")
4141+ {:ok, %Atex.AtURI{
4242+ rkey: "3jwdwj2ctlk26",
4343+ collection: "app.bsky.feed.post",
4444+ authority: "did:plc:44ybard66vv44zksje25o7dz"
4545+ }}
4646+4747+ iex> Atex.AtURI.new("at:invalid/malformed")
4848+ :error
4949+5050+ Partial URIs pointing to a collection without a record key, or even just a given authority, are also supported:
5151+5252+ iex> Atex.AtURI.new("at://ovyerus.com/sh.comet.v0.feed.track")
5353+ {:ok, %Atex.AtURI{
5454+ rkey: nil,
5555+ collection: "sh.comet.v0.feed.track",
5656+ authority: "ovyerus.com"
5757+ }}
5858+5959+ iex> Atex.AtURI.new("at://did:web:comet.sh")
6060+ {:ok, %Atex.AtURI{
6161+ rkey: nil,
6262+ collection: nil,
6363+ authority: "did:web:comet.sh"
6464+ }}
6565+ """
6666+ @spec new(String.t()) :: {:ok, t()} | :error
6767+ def new(string) when is_binary(string) do
6868+ # TODO: test different ways to get a good error from regex on which part failed match?
6969+ case Regex.named_captures(@re, string) do
7070+ %{} = captures -> {:ok, from_named_captures(captures)}
7171+ nil -> :error
7272+ end
7373+ end
7474+7575+ @doc """
7676+ The same as `new/1` but raises an `ArgumentError` if an invalid string is given.
7777+7878+ ## Examples
7979+8080+ iex> Atex.AtURI.new!("at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26")
8181+ %Atex.AtURI{
8282+ rkey: "3jwdwj2ctlk26",
8383+ collection: "app.bsky.feed.post",
8484+ authority: "did:plc:44ybard66vv44zksje25o7dz"
8585+ }
8686+8787+ iex> Atex.AtURI.new!("at:invalid/malformed")
8888+ ** (ArgumentError) Malformed at:// URI
8989+ """
9090+ @spec new!(String.t()) :: t()
9191+ def new!(string) when is_binary(string) do
9292+ case new(string) do
9393+ {:ok, uri} -> uri
9494+ :error -> raise ArgumentError, message: "Malformed at:// URI"
9595+ end
9696+ end
9797+9898+ @doc """
9999+ Check if a string is a valid `at://` URI.
100100+101101+ ## Examples
102102+103103+ iex> Atex.AtURI.match?("at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26")
104104+ true
105105+106106+ iex> Atex.AtURI.match?("at://did:web:comet.sh")
107107+ true
108108+109109+ iex> Atex.AtURI.match?("at://ovyerus.com/sh.comet.v0.feed.track")
110110+ true
111111+112112+ iex> Atex.AtURI.match?("gobbledy gook")
113113+ false
114114+ """
115115+ @spec match?(String.t()) :: boolean()
116116+ def match?(string), do: Regex.match?(@re, string)
117117+118118+ @doc """
119119+ Format an `Atex.AtURI` to the canonical string representation.
120120+121121+ Also available via the `String.Chars` protocol.
122122+123123+ ## Examples
124124+125125+ iex> aturi = %Atex.AtURI{
126126+ ...> rkey: "3jwdwj2ctlk26",
127127+ ...> collection: "app.bsky.feed.post",
128128+ ...> authority: "did:plc:44ybard66vv44zksje25o7dz"
129129+ ...> }
130130+ iex> Atex.AtURI.to_string(aturi)
131131+ "at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26"
132132+133133+ iex> aturi = %Atex.AtURI{authority: "did:web:comet.sh"}
134134+ iex> to_string(aturi)
135135+ "at://did:web:comet.sh"
136136+ """
137137+ @spec to_string(t()) :: String.t()
138138+ def to_string(%__MODULE__{} = uri) do
139139+ "at://#{uri.authority}/#{uri.collection}/#{uri.rkey}"
140140+ |> String.trim_trailing("/")
141141+ end
142142+143143+ defp from_named_captures(%{"authority" => authority, "collection" => "", "rkey" => ""}),
144144+ do: %__MODULE__{authority: authority}
145145+146146+ defp from_named_captures(%{"authority" => authority, "collection" => collection, "rkey" => ""}),
147147+ do: %__MODULE__{authority: authority, collection: collection}
148148+149149+ defp from_named_captures(%{
150150+ "authority" => authority,
151151+ "collection" => collection,
152152+ "rkey" => rkey
153153+ }),
154154+ do: %__MODULE__{authority: authority, collection: collection, rkey: rkey}
155155+end
156156+157157+defimpl String.Chars, for: Atex.AtURI do
158158+ def to_string(uri), do: Atex.AtURI.to_string(uri)
159159+end
+39
lib/atex/base32_sortable.ex
···11+defmodule Atex.Base32Sortable do
22+ @moduledoc """
33+ Codec for the base32-sortable encoding.
44+ """
55+66+ @alphabet ~c(234567abcdefghijklmnopqrstuvwxyz)
77+ @alphabet_len length(@alphabet)
88+99+ @doc """
1010+ Encode an integer as a base32-sortable string.
1111+ """
1212+ @spec encode(integer()) :: String.t()
1313+ def encode(int) when is_integer(int), do: do_encode(int, "")
1414+1515+ @spec do_encode(integer(), String.t()) :: String.t()
1616+ defp do_encode(0, acc), do: acc
1717+1818+ defp do_encode(int, acc) do
1919+ char_index = rem(int, @alphabet_len)
2020+ new_int = div(int, @alphabet_len)
2121+2222+ # Chars are prepended to the accumulator because rem/div is pulling them off the tail of the integer.
2323+ do_encode(new_int, <<Enum.at(@alphabet, char_index)>> <> acc)
2424+ end
2525+2626+ @doc """
2727+ Decode a base32-sortable string to an integer.
2828+ """
2929+ @spec decode(String.t()) :: integer()
3030+ def decode(str) when is_binary(str), do: do_decode(str, 0)
3131+3232+ @spec do_decode(String.t(), integer()) :: integer()
3333+ defp do_decode(<<>>, acc), do: acc
3434+3535+ defp do_decode(<<char::utf8, rest::binary>>, acc) do
3636+ i = Enum.find_index(@alphabet, fn x -> x == char end)
3737+ do_decode(rest, acc * @alphabet_len + i)
3838+ end
3939+end
+86
lib/atex/config/oauth.ex
···11+defmodule Atex.Config.OAuth do
22+ @moduledoc """
33+ Configuration management for `Atex.OAuth`.
44+55+ Contains all the logic for fetching configuration needed for the OAuth
66+ module, as well as deriving useful values from them.
77+88+ ## Configuration
99+1010+ The following structure is expected in your application config:
1111+1212+ config :atex, Atex.OAuth,
1313+ base_url: "https://example.com/oauth", # Your application's base URL, including the path `Atex.OAuth` is mounted on.
1414+ private_key: "base64-encoded-private-key", # ES256 private key
1515+ key_id: "your-key-id", # Key identifier for JWTs
1616+ scopes: ["transition:generic", "transition:email"], # Optional additional scopes
1717+ extra_redirect_uris: ["https://alternative.com/callback"], # Optional additional redirect URIs
1818+ is_localhost: false # Set to true for local development
1919+ """
2020+2121+ @doc """
2222+ Returns the configured public base URL for OAuth routes.
2323+ """
2424+ @spec base_url() :: String.t()
2525+ def base_url, do: Application.fetch_env!(:atex, Atex.OAuth)[:base_url]
2626+2727+ @doc """
2828+ Returns the configured private key as a `JOSE.JWK`.
2929+ """
3030+ @spec get_key() :: JOSE.JWK.t()
3131+ def get_key() do
3232+ private_key =
3333+ Application.fetch_env!(:atex, Atex.OAuth)[:private_key]
3434+ |> Base.decode64!()
3535+ |> JOSE.JWK.from_der()
3636+3737+ key_id = Application.fetch_env!(:atex, Atex.OAuth)[:key_id]
3838+3939+ %{private_key | fields: %{"kid" => key_id}}
4040+ end
4141+4242+ @doc """
4343+ Returns the client ID based on configuration.
4444+4545+ If `is_localhost` is set, it'll be a string handling the "http://localhost"
4646+ special case, with the redirect URI and scopes configured, otherwise it is a
4747+ string pointing to the location of the `client-metadata.json` route.
4848+ """
4949+ @spec client_id() :: String.t()
5050+ def client_id() do
5151+ is_localhost = Keyword.get(Application.get_env(:atex, Atex.OAuth, []), :is_localhost, false)
5252+5353+ if is_localhost do
5454+ query =
5555+ %{redirect_uri: redirect_uri(), scope: scopes()}
5656+ |> URI.encode_query()
5757+5858+ "http://localhost?#{query}"
5959+ else
6060+ "#{base_url()}/client-metadata.json"
6161+ end
6262+ end
6363+6464+ @doc """
6565+ Returns the configured redirect URI.
6666+ """
6767+ @spec redirect_uri() :: String.t()
6868+ def redirect_uri(), do: "#{base_url()}/callback"
6969+7070+ @doc """
7171+ Returns the configured scopes joined as a space-separated string.
7272+ """
7373+ @spec scopes() :: String.t()
7474+ def scopes() do
7575+ config_scopes = Keyword.get(Application.get_env(:atex, Atex.OAuth, []), :scopes, [])
7676+ Enum.join(["atproto" | config_scopes], " ")
7777+ end
7878+7979+ @doc """
8080+ Returns the configured extra redirect URIs.
8181+ """
8282+ @spec extra_redirect_uris() :: [String.t()]
8383+ def extra_redirect_uris() do
8484+ Keyword.get(Application.get_env(:atex, Atex.OAuth, []), :extra_redirect_uris, [])
8585+ end
8686+end
···11+defmodule Atex.IdentityResolver.Cache.ETS do
22+ @moduledoc """
33+ ConCache-based implementation for Identity Resolver caching.
44+55+ Stores identity information (DID and handle mappings) with a 1-hour TTL.
66+ Uses two separate cache entries per identity to allow lookups by either DID or handle.
77+ """
88+99+ alias Atex.IdentityResolver.Identity
1010+ @behaviour Atex.IdentityResolver.Cache
1111+ use Supervisor
1212+1313+ @cache :atex_identities_cache
1414+ @ttl_ms :timer.hours(1)
1515+1616+ def start_link(opts) do
1717+ Supervisor.start_link(__MODULE__, opts, name: __MODULE__)
1818+ end
1919+2020+ @impl Supervisor
2121+ def init(_opts) do
2222+ children = [
2323+ {ConCache,
2424+ [
2525+ name: @cache,
2626+ ttl_check_interval: :timer.minutes(5),
2727+ global_ttl: @ttl_ms
2828+ ]}
2929+ ]
3030+3131+ Supervisor.init(children, strategy: :one_for_one)
3232+ end
3333+3434+ @impl Atex.IdentityResolver.Cache
3535+ @spec insert(Identity.t()) :: Identity.t()
3636+ def insert(identity) do
3737+ ConCache.put(@cache, {:did, identity.did}, identity)
3838+ ConCache.put(@cache, {:handle, identity.handle}, identity)
3939+ identity
4040+ end
4141+4242+ @impl Atex.IdentityResolver.Cache
4343+ @spec get(String.t()) :: {:ok, Identity.t()} | {:error, atom()}
4444+ def get(identifier) do
4545+ case ConCache.get(@cache, {:did, identifier}) do
4646+ nil ->
4747+ case ConCache.get(@cache, {:handle, identifier}) do
4848+ nil -> {:error, :not_found}
4949+ identity -> {:ok, identity}
5050+ end
5151+5252+ identity ->
5353+ {:ok, identity}
5454+ end
5555+ end
5656+5757+ @impl Atex.IdentityResolver.Cache
5858+ @spec delete(String.t()) :: :noop | Identity.t()
5959+ def delete(identifier) do
6060+ case get(identifier) do
6161+ {:ok, identity} ->
6262+ ConCache.delete(@cache, {:did, identity.did})
6363+ ConCache.delete(@cache, {:handle, identity.handle})
6464+ identity
6565+6666+ _ ->
6767+ :noop
6868+ end
6969+ end
7070+end
+42
lib/atex/identity_resolver/cache.ex
···11+defmodule Atex.IdentityResolver.Cache do
22+ # TODO: need the following:
33+ # did -> handle mapping
44+ # handle -> did mapping
55+ # did -> document mapping?
66+ # User should be able to call a single function to fetch all info for either did and handle, including the link between them.
77+ # Need some sort of TTL so that we can refresh as necessary
88+ alias Atex.IdentityResolver.Identity
99+1010+ @cache Application.compile_env(:atex, :identity_cache, Atex.IdentityResolver.Cache.ETS)
1111+1212+ @doc """
1313+ Add a new identity to the cache. Can also be used to update an identity that may already exist.
1414+1515+ Returns the input `t:Atex.IdentityResolver.Identity.t/0`.
1616+ """
1717+ @callback insert(identity :: Identity.t()) :: Identity.t()
1818+1919+ @doc """
2020+ Retrieve an identity from the cache by DID *or* handle.
2121+ """
2222+ @callback get(String.t()) :: {:ok, Identity.t()} | {:error, atom()}
2323+2424+ @doc """
2525+ Delete an identity in the cache.
2626+ """
2727+ @callback delete(String.t()) :: :noop | Identity.t()
2828+2929+ @doc """
3030+ Get the child specification for starting the cache in a supervision tree.
3131+ """
3232+ @callback child_spec(any()) :: Supervisor.child_spec()
3333+3434+ defdelegate get(identifier), to: @cache
3535+3636+ @doc false
3737+ defdelegate insert(payload), to: @cache
3838+ @doc false
3939+ defdelegate delete(snowflake), to: @cache
4040+ @doc false
4141+ defdelegate child_spec(opts), to: @cache
4242+end
+51
lib/atex/identity_resolver/did.ex
···11+defmodule Atex.IdentityResolver.DID do
22+ alias Atex.IdentityResolver.DIDDocument
33+44+ @type resolution_result() ::
55+ {:ok, DIDDocument.t()}
66+ | {:error, :invalid_did_type | :invalid_did | :not_found | map() | atom() | any()}
77+88+ @spec resolve(String.t()) :: resolution_result()
99+ def resolve("did:plc:" <> _ = did), do: resolve_plc(did)
1010+ def resolve("did:web:" <> _ = did), do: resolve_web(did)
1111+ def resolve("did:" <> _), do: {:error, :invalid_did_type}
1212+ def resolve(_did), do: {:error, :invalid_did}
1313+1414+ @spec resolve_plc(String.t()) :: resolution_result()
1515+ defp resolve_plc("did:plc:" <> _id = did) do
1616+ with {:ok, resp} when resp.status in 200..299 <-
1717+ Req.get("https://plc.directory/#{did}"),
1818+ {:ok, body} <- decode_body(resp.body),
1919+ {:ok, document} <- DIDDocument.from_json(body),
2020+ :ok <- DIDDocument.validate_for_atproto(document, did) do
2121+ {:ok, document}
2222+ else
2323+ {:ok, %{status: status}} when status in [404, 410] -> {:error, :not_found}
2424+ {:ok, %{} = resp} -> {:error, resp}
2525+ e -> e
2626+ end
2727+ end
2828+2929+ @spec resolve_web(String.t()) :: resolution_result()
3030+ defp resolve_web("did:web:" <> domain = did) do
3131+ with {:ok, resp} when resp.status in 200..299 <-
3232+ Req.get("https://#{domain}/.well-known/did.json"),
3333+ {:ok, body} <- decode_body(resp.body),
3434+ {:ok, document} <- DIDDocument.from_json(body),
3535+ :ok <- DIDDocument.validate_for_atproto(document, did) do
3636+ {:ok, document}
3737+ else
3838+ {:ok, %{status: 404}} -> {:error, :not_found}
3939+ {:ok, %{} = resp} -> {:error, resp}
4040+ e -> e
4141+ end
4242+ end
4343+4444+ @spec decode_body(any()) ::
4545+ {:ok, any()}
4646+ | {:error, :invalid_body | JSON.decode_error_reason()}
4747+4848+ defp decode_body(body) when is_binary(body), do: JSON.decode(body)
4949+ defp decode_body(body) when is_map(body), do: {:ok, body}
5050+ defp decode_body(_body), do: {:error, :invalid_body}
5151+end
+155
lib/atex/identity_resolver/did_document.ex
···11+defmodule Atex.IdentityResolver.DIDDocument do
22+ @moduledoc """
33+ Struct and schema for describing and validating a [DID document](https://github.com/w3c/did-wg/blob/main/did-explainer.md#did-documents).
44+ """
55+ import Peri
66+ use TypedStruct
77+88+ defschema :schema, %{
99+ "@context": {:required, {:list, Atex.Peri.uri()}},
1010+ id: {:required, :string},
1111+ controller: {:either, {Atex.Peri.did(), {:list, Atex.Peri.did()}}},
1212+ also_known_as: {:list, Atex.Peri.uri()},
1313+ verification_method: {:list, get_schema(:verification_method)},
1414+ authentication: {:list, {:either, {Atex.Peri.uri(), get_schema(:verification_method)}}},
1515+ service: {:list, get_schema(:service)}
1616+ }
1717+1818+ defschema :verification_method, %{
1919+ id: {:required, Atex.Peri.uri()},
2020+ type: {:required, :string},
2121+ controller: {:required, Atex.Peri.did()},
2222+ public_key_multibase: :string,
2323+ public_key_jwk: :map
2424+ }
2525+2626+ defschema :service, %{
2727+ id: {:required, Atex.Peri.uri()},
2828+ type: {:required, {:either, {:string, {:list, :string}}}},
2929+ service_endpoint:
3030+ {:required,
3131+ {:oneof,
3232+ [
3333+ Atex.Peri.uri(),
3434+ {:map, Atex.Peri.uri()},
3535+ {:list, {:either, {Atex.Peri.uri(), {:map, Atex.Peri.uri()}}}}
3636+ ]}}
3737+ }
3838+3939+ @type verification_method() :: %{
4040+ required(:id) => String.t(),
4141+ required(:type) => String.t(),
4242+ required(:controller) => String.t(),
4343+ optional(:public_key_multibase) => String.t(),
4444+ optional(:public_key_jwk) => map()
4545+ }
4646+4747+ @type service() :: %{
4848+ required(:id) => String.t(),
4949+ required(:type) => String.t() | list(String.t()),
5050+ required(:service_endpoint) =>
5151+ String.t()
5252+ | %{String.t() => String.t()}
5353+ | list(String.t() | %{String.t() => String.t()})
5454+ }
5555+5656+ typedstruct do
5757+ field :"@context", list(String.t()), enforce: true
5858+ field :id, String.t(), enforce: true
5959+ field :controller, String.t() | list(String.t())
6060+ field :also_known_as, list(String.t())
6161+ field :verification_method, list(verification_method())
6262+ field :authentication, list(String.t() | verification_method())
6363+ field :service, list(service())
6464+ end
6565+6666+ def new(params), do: struct(__MODULE__, params)
6767+6868+ @spec from_json(map()) :: {:ok, t()} | {:error, Peri.Error.t()}
6969+ def from_json(%{} = map) do
7070+ map
7171+ |> Recase.Enumerable.convert_keys(&Recase.to_snake/1)
7272+ |> schema()
7373+ |> case do
7474+ # {:ok, params} -> {:ok, struct(__MODULE__, params)}
7575+ {:ok, params} -> {:ok, new(params)}
7676+ e -> e
7777+ end
7878+ end
7979+8080+ @spec validate_for_atproto(t(), String.t()) :: any()
8181+ def validate_for_atproto(%__MODULE__{} = doc, did) do
8282+ # TODO: make sure this is ok
8383+ id_matches = doc.id == did
8484+8585+ valid_signing_key =
8686+ Enum.any?(doc.verification_method, fn method ->
8787+ String.ends_with?(method.id, "#atproto") and method.controller == did
8888+ end)
8989+9090+ valid_pds_service =
9191+ Enum.any?(doc.service, fn service ->
9292+ String.ends_with?(service.id, "#atproto_pds") and
9393+ service.type == "AtprotoPersonalDataServer" and
9494+ valid_pds_endpoint?(service.service_endpoint)
9595+ end)
9696+9797+ case {id_matches, valid_signing_key, valid_pds_service} do
9898+ {true, true, true} -> :ok
9999+ {false, _, _} -> {:error, :id_mismatch}
100100+ {_, false, _} -> {:error, :no_signing_key}
101101+ {_, _, false} -> {:error, :invalid_pds}
102102+ end
103103+ end
104104+105105+ @doc """
106106+ Get the associated ATProto handle in the DID document.
107107+108108+ ATProto dictates that only the first valid handle is to be used, so this
109109+ follows that rule.
110110+111111+ > #### Note {: .info}
112112+ >
113113+ > While DID documents are fairly authoritative, you need to make sure to
114114+ > validate the handle bidirectionally. See
115115+ > `Atex.IdentityResolver.Handle.resolve/2`.
116116+ """
117117+ @spec get_atproto_handle(t()) :: String.t() | nil
118118+ def get_atproto_handle(%__MODULE__{also_known_as: nil}), do: nil
119119+120120+ def get_atproto_handle(%__MODULE__{} = doc) do
121121+ Enum.find_value(doc.also_known_as, fn
122122+ # TODO: make sure no path or other URI parts
123123+ "at://" <> handle -> handle
124124+ _ -> nil
125125+ end)
126126+ end
127127+128128+ @spec get_pds_endpoint(t()) :: String.t() | nil
129129+ def get_pds_endpoint(%__MODULE__{} = doc) do
130130+ doc.service
131131+ |> Enum.find(fn
132132+ %{id: "#atproto_pds", type: "AtprotoPersonalDataServer"} -> true
133133+ _ -> false
134134+ end)
135135+ |> case do
136136+ nil -> nil
137137+ pds -> pds.service_endpoint
138138+ end
139139+ end
140140+141141+ defp valid_pds_endpoint?(endpoint) do
142142+ case URI.new(endpoint) do
143143+ {:ok, uri} ->
144144+ is_plain_uri =
145145+ uri
146146+ |> Map.from_struct()
147147+ |> Enum.all?(fn
148148+ {key, value} when key in [:userinfo, :path, :query, :fragment] -> is_nil(value)
149149+ _ -> true
150150+ end)
151151+152152+ uri.scheme in ["https", "http"] and is_plain_uri
153153+ end
154154+ end
155155+end
+74
lib/atex/identity_resolver/handle.ex
···11+defmodule Atex.IdentityResolver.Handle do
22+ @type strategy() :: :dns_first | :http_first | :race | :both
33+44+ @spec resolve(String.t(), strategy()) ::
55+ {:ok, String.t()} | :error | {:error, :ambiguous_handle}
66+ def resolve(handle, strategy)
77+88+ def resolve(handle, :dns_first) do
99+ case resolve_via_dns(handle) do
1010+ :error -> resolve_via_http(handle)
1111+ ok -> ok
1212+ end
1313+ end
1414+1515+ def resolve(handle, :http_first) do
1616+ case resolve_via_http(handle) do
1717+ :error -> resolve_via_dns(handle)
1818+ ok -> ok
1919+ end
2020+ end
2121+2222+ def resolve(handle, :race) do
2323+ [&resolve_via_dns/1, &resolve_via_http/1]
2424+ |> Task.async_stream(& &1.(handle), max_concurrency: 2, ordered: false)
2525+ |> Stream.filter(&match?({:ok, {:ok, _}}, &1))
2626+ |> Enum.at(0)
2727+ end
2828+2929+ def resolve(handle, :both) do
3030+ case Task.await_many([
3131+ Task.async(fn -> resolve_via_dns(handle) end),
3232+ Task.async(fn -> resolve_via_http(handle) end)
3333+ ]) do
3434+ [{:ok, dns_did}, {:ok, http_did}] ->
3535+ if dns_did && http_did && dns_did != http_did do
3636+ {:error, :ambiguous_handle}
3737+ else
3838+ {:ok, dns_did}
3939+ end
4040+4141+ _ ->
4242+ :error
4343+ end
4444+ end
4545+4646+ @spec resolve_via_dns(String.t()) :: {:ok, String.t()} | :error
4747+ defp resolve_via_dns(handle) do
4848+ with ["did=" <> did] <- query_dns("_atproto.#{handle}", :txt),
4949+ "did:" <> _ <- did do
5050+ {:ok, did}
5151+ else
5252+ _ -> :error
5353+ end
5454+ end
5555+5656+ @spec resolve_via_http(String.t()) :: {:ok, String.t()} | :error
5757+ defp resolve_via_http(handle) do
5858+ case Req.get("https://#{handle}/.well-known/atproto-did") do
5959+ {:ok, %{body: "did:" <> _ = did}} -> {:ok, did}
6060+ _ -> :error
6161+ end
6262+ end
6363+6464+ @spec query_dns(String.t(), :inet_res.dns_rr_type()) :: list(String.t() | list(String.t()))
6565+ defp query_dns(domain, type) do
6666+ domain
6767+ |> String.to_charlist()
6868+ |> :inet_res.lookup(:in, type)
6969+ |> Enum.map(fn
7070+ [result] -> to_string(result)
7171+ result -> result
7272+ end)
7373+ end
7474+end
+25
lib/atex/identity_resolver/identity.ex
···11+defmodule Atex.IdentityResolver.Identity do
22+ use TypedStruct
33+44+ @typedoc """
55+ The controlling DID for an identity.
66+ """
77+ @type did() :: String.t()
88+ @typedoc """
99+ The human-readable handle for an identity. Can be missing.
1010+ """
1111+ @type handle() :: String.t() | nil
1212+ @typedoc """
1313+ The resolved DID document for an identity.
1414+ """
1515+ @type document() :: Atex.IdentityResolver.DIDDocument.t()
1616+1717+ typedstruct do
1818+ field :did, did(), enforce: true
1919+ field :handle, handle()
2020+ field :document, document(), enforce: true
2121+ end
2222+2323+ @spec new(did(), handle(), document()) :: t()
2424+ def new(did, handle, document), do: %__MODULE__{did: did, handle: handle, document: document}
2525+end
+56
lib/atex/identity_resolver.ex
···11+defmodule Atex.IdentityResolver do
22+ alias Atex.IdentityResolver.{Cache, DID, DIDDocument, Handle, Identity}
33+44+ @handle_strategy Application.compile_env(:atex, :handle_resolver_strategy, :dns_first)
55+ @type options() :: {:skip_cache, boolean()}
66+77+ # TODO: simplify errors
88+99+ @spec resolve(String.t(), list(options())) :: {:ok, Identity.t()} | {:error, any()}
1010+ def resolve(identifier, opts \\ []) do
1111+ opts = Keyword.validate!(opts, skip_cache: false)
1212+ skip_cache = Keyword.get(opts, :skip_cache)
1313+1414+ cache_result = if skip_cache, do: {:error, :not_found}, else: Cache.get(identifier)
1515+1616+ # If cache fetch succeeds, then the ok tuple will be retuned by the default `with` behaviour
1717+ with {:error, :not_found} <- cache_result,
1818+ {:ok, identity} <- do_resolve(identifier),
1919+ identity <- Cache.insert(identity) do
2020+ {:ok, identity}
2121+ end
2222+ end
2323+2424+ @spec do_resolve(identity :: String.t()) ::
2525+ {:ok, Identity.t()}
2626+ | {:error, :handle_mismatch}
2727+ | {:error, any()}
2828+ defp do_resolve("did:" <> _ = did) do
2929+ with {:ok, document} <- DID.resolve(did),
3030+ :ok <- DIDDocument.validate_for_atproto(document, did) do
3131+ with handle when not is_nil(handle) <- DIDDocument.get_atproto_handle(document),
3232+ {:ok, handle_did} <- Handle.resolve(handle, @handle_strategy),
3333+ true <- handle_did == did do
3434+ {:ok, Identity.new(did, handle, document)}
3535+ else
3636+ # Not having a handle, while a little un-ergonomic, is totally valid.
3737+ nil -> {:ok, Identity.new(did, nil, document)}
3838+ false -> {:error, :handle_mismatch}
3939+ e -> e
4040+ end
4141+ end
4242+ end
4343+4444+ defp do_resolve(handle) do
4545+ with {:ok, did} <- Handle.resolve(handle, @handle_strategy),
4646+ {:ok, document} <- DID.resolve(did),
4747+ did_handle when not is_nil(handle) <- DIDDocument.get_atproto_handle(document),
4848+ true <- did_handle == handle do
4949+ {:ok, Identity.new(did, handle, document)}
5050+ else
5151+ nil -> {:error, :handle_mismatch}
5252+ false -> {:error, :handle_mismatch}
5353+ e -> e
5454+ end
5555+ end
5656+end
···11+defmodule Atex.Lexicon do
22+ alias Atex.Lexicon.Validators
33+44+ defmacro __using__(_opts) do
55+ quote do
66+ import Atex.Lexicon
77+ import Atex.Lexicon.Validators
88+ import Peri
99+ end
1010+ end
1111+1212+ @doc """
1313+ Defines a lexicon module from a JSON lexicon definition.
1414+1515+ The `deflexicon` macro processes the provided lexicon map (typically loaded
1616+ from a JSON file) and generates:
1717+1818+ - **Typespecs** for each definition, exposing a `t/0` type for the main
1919+ definition and named types for any additional definitions.
2020+ - **`Peri` schemas** via `defschema/2` for runtime validation of data.
2121+ - **Structs** for object and record definitions, with `@enforce_keys` ensuring
2222+ required fields are present.
2323+ - For **queries** and **procedures**, it creates structs for `params`,
2424+ `input`, and `output` when those sections exist in the lexicon. It also
2525+ generates a topโlevel struct that aggregates `params` and `input` (when
2626+ applicable); this struct is used by the XRPC client to locate the
2727+ appropriate output struct.
2828+2929+ If a procedure doesn't have a schema for a JSON body specified as it's input,
3030+ the top-level struct will instead have a `raw_input` field, allowing for
3131+ miscellaneous bodies such as a binary blob.
3232+3333+ The generated structs also implement the `JSON.Encoder` and `Jason.Encoder`
3434+ protocols (the latter currently present for compatibility), as well as a
3535+ `from_json` function which is used to validate an input map - e.g. from a JSON
3636+ HTTP response - and turn it into a struct.
3737+3838+ ## Example
3939+4040+ deflexicon(%{
4141+ "lexicon" => 1,
4242+ "id" => "com.ovyerus.testing",
4343+ "defs" => %{
4444+ "main" => %{
4545+ "type" => "record",
4646+ "key" => "tid",
4747+ "record" => %{
4848+ "type" => "object",
4949+ "required" => ["foobar"],
5050+ "properties" => %{ "foobar" => %{ "type" => "string" } }
5151+ }
5252+ }
5353+ }
5454+ })
5555+5656+ The macro expands to following code (truncated for brevity):
5757+5858+ @type main() :: %{required(:foobar) => String.t(), optional(:"$type") => String.t()}
5959+ @type t() :: %{required(:foobar) => String.t(), optional(:"$type") => String.t()}
6060+6161+ defschema(:main, %{
6262+ foobar: {:required, {:custom, {Atex.Lexicon.Validators.String, :validate, [[]]}}},
6363+ "$type": {{:literal, "com.ovyerus.testing"}, {:default, "com.ovyerus.testing"}}
6464+ })
6565+6666+ @enforce_keys [:foobar]
6767+ defstruct foobar: nil, "$type": "com.ovyerus.testing"
6868+6969+ def from_json(json) do
7070+ case apply(Com.Ovyerus.Testing, :main, [json]) do
7171+ {:ok, map} -> {:ok, struct(__MODULE__, map)}
7272+ err -> err
7373+ end
7474+ end
7575+7676+ The generated module can be used directly with `Atex.XRPC` functions, allowing
7777+ typeโsafe construction of requests and automatic decoding of responses.
7878+ """
7979+ defmacro deflexicon(lexicon) do
8080+ # Better way to get the real map, without having to eval? (custom function to compose one from quoted?)
8181+ lexicon =
8282+ lexicon
8383+ |> Code.eval_quoted()
8484+ |> elem(0)
8585+ |> then(&Recase.Enumerable.atomize_keys/1)
8686+ |> then(&Atex.Lexicon.Schema.lexicon!/1)
8787+8888+ defs =
8989+ lexicon.defs
9090+ |> Enum.flat_map(fn {def_name, def} -> def_to_schema(lexicon.id, def_name, def) end)
9191+ |> Enum.map(fn
9292+ {schema_key, quoted_schema, quoted_type} -> {schema_key, quoted_schema, quoted_type, nil}
9393+ x -> x
9494+ end)
9595+ |> Enum.map(fn {schema_key, quoted_schema, quoted_type, quoted_struct} ->
9696+ identity_type =
9797+ if schema_key == :main do
9898+ quote do
9999+ @type t() :: unquote(quoted_type)
100100+ end
101101+ end
102102+103103+ struct_def =
104104+ if schema_key == :main do
105105+ quoted_struct
106106+ else
107107+ nested_module_name =
108108+ schema_key
109109+ |> Recase.to_pascal()
110110+ |> atomise()
111111+112112+ quote do
113113+ defmodule unquote({:__aliases__, [alias: false], [nested_module_name]}) do
114114+ unquote(quoted_struct)
115115+ end
116116+ end
117117+ end
118118+119119+ quote do
120120+ @type unquote(Recase.to_snake(schema_key))() :: unquote(quoted_type)
121121+ unquote(identity_type)
122122+123123+ defschema unquote(Recase.to_snake(schema_key)), unquote(quoted_schema)
124124+125125+ unquote(struct_def)
126126+ end
127127+ end)
128128+129129+ quote do
130130+ def id, do: unquote(lexicon.id)
131131+132132+ unquote_splicing(defs)
133133+ end
134134+ end
135135+136136+ # - [ ] `t()` type should be the struct in it. (add to non-main structs too?)
137137+138138+ @spec def_to_schema(nsid :: String.t(), def_name :: String.t(), lexicon_def :: map()) ::
139139+ list(
140140+ {
141141+ key :: atom(),
142142+ quoted_schema :: term(),
143143+ quoted_type :: term()
144144+ }
145145+ | {
146146+ key :: atom(),
147147+ quoted_schema :: term(),
148148+ quoted_type :: term(),
149149+ quoted_struct :: term()
150150+ }
151151+ )
152152+153153+ defp def_to_schema(nsid, def_name, %{type: "record", record: record}) do
154154+ # TODO: record rkey format validator
155155+ type_name = Atex.NSID.canonical_name(nsid, to_string(def_name))
156156+157157+ record =
158158+ put_in(record, [:properties, :"$type"], %{
159159+ type: "string",
160160+ const: type_name,
161161+ default: type_name
162162+ })
163163+164164+ def_to_schema(nsid, def_name, record)
165165+ end
166166+167167+ # TODO: add struct to types
168168+ defp def_to_schema(
169169+ nsid,
170170+ def_name,
171171+ %{
172172+ type: "object",
173173+ properties: properties
174174+ } = def
175175+ ) do
176176+ required = Map.get(def, :required, [])
177177+ nullable = Map.get(def, :nullable, [])
178178+179179+ {quoted_schemas, quoted_types} =
180180+ properties
181181+ |> Enum.map(fn {key, field} ->
182182+ {quoted_schema, quoted_type} = field_to_schema(field, nsid)
183183+ string_key = to_string(key)
184184+ is_nullable = string_key in nullable
185185+ is_required = string_key in required
186186+187187+ quoted_schema =
188188+ quoted_schema
189189+ |> then(
190190+ &if is_nullable, do: quote(do: {:either, {{:literal, nil}, unquote(&1)}}), else: &1
191191+ )
192192+ |> then(&if is_required, do: quote(do: {:required, unquote(&1)}), else: &1)
193193+ |> then(&{key, &1})
194194+195195+ key_type = if is_required, do: :required, else: :optional
196196+197197+ quoted_type =
198198+ quoted_type
199199+ |> then(
200200+ &if is_nullable do
201201+ {:|, [], [&1, nil]}
202202+ else
203203+ &1
204204+ end
205205+ )
206206+ |> then(&{{key_type, [], [key]}, &1})
207207+208208+ {quoted_schema, quoted_type}
209209+ end)
210210+ |> Enum.reduce({[], []}, fn {quoted_schema, quoted_type}, {schemas, types} ->
211211+ {[quoted_schema | schemas], [quoted_type | types]}
212212+ end)
213213+214214+ struct_keys =
215215+ properties
216216+ |> Enum.filter(fn {key, _} -> key !== :"$type" end)
217217+ |> Enum.map(fn
218218+ {key, %{default: default}} -> {key, default}
219219+ {key, _field} -> {key, nil}
220220+ end)
221221+ |> then(&(&1 ++ [{:"$type", if(def_name == :main, do: nsid, else: "#{nsid}##{def_name}")}]))
222222+223223+ enforced_keys =
224224+ properties |> Map.keys() |> Enum.filter(&(to_string(&1) in required && &1 != :"$type"))
225225+226226+ optional_if_nil_keys =
227227+ properties
228228+ |> Map.keys()
229229+ |> Enum.filter(fn key ->
230230+ key = to_string(key)
231231+ # TODO: what if it is nullable but not required?
232232+ key not in required && key not in nullable && key != "$type"
233233+ end)
234234+235235+ schema_module = Atex.NSID.to_atom(nsid)
236236+237237+ quoted_struct =
238238+ quote do
239239+ @enforce_keys unquote(enforced_keys)
240240+ defstruct unquote(struct_keys)
241241+242242+ def from_json(json) do
243243+ case apply(unquote(schema_module), unquote(atomise(def_name)), [json]) do
244244+ {:ok, map} -> {:ok, struct(__MODULE__, map)}
245245+ err -> err
246246+ end
247247+ end
248248+249249+ defimpl JSON.Encoder do
250250+ @optional_if_nil_keys unquote(optional_if_nil_keys)
251251+252252+ def encode(value, encoder) do
253253+ value
254254+ |> Map.from_struct()
255255+ |> Enum.reject(fn {k, v} -> k in @optional_if_nil_keys && v == nil end)
256256+ |> Enum.into(%{})
257257+ |> Jason.Encoder.encode(encoder)
258258+ end
259259+ end
260260+261261+ defimpl Jason.Encoder do
262262+ @optional_if_nil_keys unquote(optional_if_nil_keys)
263263+264264+ def encode(value, options) do
265265+ value
266266+ |> Map.from_struct()
267267+ |> Enum.reject(fn {k, v} -> k in @optional_if_nil_keys && v == nil end)
268268+ |> Enum.into(%{})
269269+ |> Jason.Encode.map(options)
270270+ end
271271+ end
272272+ end
273273+274274+ [{atomise(def_name), {:%{}, [], quoted_schemas}, {:%{}, [], quoted_types}, quoted_struct}]
275275+ end
276276+277277+ # TODO: validating errors?
278278+ defp def_to_schema(nsid, _def_name, %{type: "query"} = def) do
279279+ params =
280280+ if def[:parameters] do
281281+ [schema] =
282282+ def_to_schema(nsid, "params", %{
283283+ type: "object",
284284+ required: Map.get(def.parameters, :required, []),
285285+ properties: def.parameters.properties
286286+ })
287287+288288+ schema
289289+ end
290290+291291+ output =
292292+ if def[:output] && def.output[:schema] do
293293+ [schema] = def_to_schema(nsid, "output", def.output.schema)
294294+ schema
295295+ end
296296+297297+ # Root struct containing `params`
298298+ main =
299299+ if params do
300300+ {
301301+ :main,
302302+ nil,
303303+ quote do
304304+ %__MODULE__{params: params()}
305305+ end,
306306+ quote do
307307+ @enforce_keys [:params]
308308+ defstruct params: nil
309309+ end
310310+ }
311311+ else
312312+ {
313313+ :main,
314314+ nil,
315315+ quote do
316316+ %__MODULE__{}
317317+ end,
318318+ quote do
319319+ defstruct []
320320+ end
321321+ }
322322+ end
323323+324324+ [main, params, output]
325325+ |> Enum.reject(&is_nil/1)
326326+ end
327327+328328+ defp def_to_schema(nsid, _def_name, %{type: "procedure"} = def) do
329329+ # TODO: better keys for these
330330+ params =
331331+ if def[:parameters] do
332332+ [schema] =
333333+ def_to_schema(nsid, "params", %{
334334+ type: "object",
335335+ required: Map.get(def.parameters, :required, []),
336336+ properties: def.parameters.properties
337337+ })
338338+339339+ schema
340340+ end
341341+342342+ output =
343343+ if def[:output] && def.output[:schema] do
344344+ [schema] = def_to_schema(nsid, "output", def.output.schema)
345345+ schema
346346+ end
347347+348348+ input =
349349+ if def[:input] && def.input[:schema] do
350350+ [schema] = def_to_schema(nsid, "input", def.input.schema)
351351+ schema
352352+ end
353353+354354+ # Root struct containing `input`, `raw_input`, and `params`
355355+ main =
356356+ {
357357+ :main,
358358+ nil,
359359+ cond do
360360+ params && input ->
361361+ quote do
362362+ %__MODULE__{input: input(), params: params()}
363363+ end
364364+365365+ input ->
366366+ quote do
367367+ %__MODULE__{input: input()}
368368+ end
369369+370370+ params ->
371371+ quote do
372372+ %__MODULE__{raw_input: any(), params: params()}
373373+ end
374374+375375+ true ->
376376+ quote do
377377+ %__MODULE__{raw_input: any()}
378378+ end
379379+ end,
380380+ cond do
381381+ params && input ->
382382+ quote do
383383+ defstruct input: nil, params: nil
384384+ end
385385+386386+ input ->
387387+ quote do
388388+ defstruct input: nil
389389+ end
390390+391391+ params ->
392392+ quote do
393393+ defstruct raw_input: nil, params: nil
394394+ end
395395+396396+ true ->
397397+ quote do
398398+ defstruct raw_input: nil
399399+ end
400400+ end
401401+ }
402402+403403+ [main, params, output, input]
404404+ |> Enum.reject(&is_nil/1)
405405+ end
406406+407407+ defp def_to_schema(nsid, _def_name, %{type: "subscription"} = def) do
408408+ params =
409409+ if def[:parameters] do
410410+ [schema] =
411411+ def_to_schema(nsid, "params", %{
412412+ type: "object",
413413+ required: Map.get(def.parameters, :required, []),
414414+ properties: def.parameters.properties
415415+ })
416416+417417+ schema
418418+ end
419419+420420+ message =
421421+ if def[:message] do
422422+ [schema] = def_to_schema(nsid, "message", def.message.schema)
423423+ schema
424424+ end
425425+426426+ [params, message]
427427+ |> Enum.reject(&is_nil/1)
428428+ end
429429+430430+ defp def_to_schema(_nsid, def_name, %{type: "token"}) do
431431+ # TODO: make it a validator that expects the nsid + key.
432432+ [
433433+ {
434434+ atomise(def_name),
435435+ :string,
436436+ quote do
437437+ String.t()
438438+ end
439439+ }
440440+ ]
441441+ end
442442+443443+ defp def_to_schema(nsid, def_name, %{type: type} = def)
444444+ when type in [
445445+ "blob",
446446+ "array",
447447+ "boolean",
448448+ "integer",
449449+ "string",
450450+ "bytes",
451451+ "cid-link",
452452+ "unknown",
453453+ "ref",
454454+ "union"
455455+ ] do
456456+ {quoted_schema, quoted_type} = field_to_schema(def, nsid)
457457+ [{atomise(def_name), quoted_schema, quoted_type}]
458458+ end
459459+460460+ @spec field_to_schema(field_def :: %{type: String.t()}, nsid :: String.t()) ::
461461+ {quoted_schema :: term(), quoted_typespec :: term()}
462462+ defp field_to_schema(%{type: "string"} = field, _nsid) do
463463+ fixed_schema = const_or_enum(field)
464464+465465+ if fixed_schema do
466466+ maybe_default(fixed_schema, field)
467467+ else
468468+ field
469469+ |> Map.take([
470470+ :format,
471471+ :maxLength,
472472+ :minLength,
473473+ :maxGraphemes,
474474+ :minGraphemes
475475+ ])
476476+ |> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end)
477477+ |> Validators.string()
478478+ |> maybe_default(field)
479479+ end
480480+ |> then(
481481+ &{Macro.escape(&1),
482482+ quote do
483483+ String.t()
484484+ end}
485485+ )
486486+ end
487487+488488+ defp field_to_schema(%{type: "boolean"} = field, _nsid) do
489489+ (const(field) || :boolean)
490490+ |> maybe_default(field)
491491+ |> then(
492492+ &{Macro.escape(&1),
493493+ quote do
494494+ boolean()
495495+ end}
496496+ )
497497+ end
498498+499499+ defp field_to_schema(%{type: "integer"} = field, _nsid) do
500500+ fixed_schema = const_or_enum(field)
501501+502502+ if fixed_schema do
503503+ maybe_default(fixed_schema, field)
504504+ else
505505+ field
506506+ |> Map.take([:maximum, :minimum])
507507+ |> Keyword.new()
508508+ |> Validators.integer()
509509+ |> maybe_default(field)
510510+ end
511511+ |> then(
512512+ &{
513513+ Macro.escape(&1),
514514+ # TODO: turn into range definition based on maximum/minimum
515515+ quote do
516516+ integer()
517517+ end
518518+ }
519519+ )
520520+ end
521521+522522+ defp field_to_schema(%{type: "array", items: items} = field, nsid) do
523523+ {inner_schema, inner_type} = field_to_schema(items, nsid)
524524+525525+ field
526526+ |> Map.take([:maxLength, :minLength])
527527+ |> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end)
528528+ |> then(&Validators.array(inner_schema, &1))
529529+ |> then(&Macro.escape/1)
530530+ # TODO: we should be able to unquote this now...
531531+ # Can't unquote the inner_schema beforehand as that would risk evaluating `get_schema`s which don't exist yet.
532532+ # There's probably a better way to do this lol.
533533+ |> then(fn {:custom, {:{}, c, [Validators.Array, :validate, [quoted_inner_schema | args]]}} ->
534534+ {inner_schema, _} = Code.eval_quoted(quoted_inner_schema)
535535+ {:custom, {:{}, c, [Validators.Array, :validate, [inner_schema | args]]}}
536536+ end)
537537+ |> then(
538538+ &{&1,
539539+ quote do
540540+ list(unquote(inner_type))
541541+ end}
542542+ )
543543+ end
544544+545545+ defp field_to_schema(%{type: "blob"} = field, _nsid) do
546546+ field
547547+ |> Map.take([:accept, :maxSize])
548548+ |> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end)
549549+ |> Validators.blob()
550550+ |> then(
551551+ &{Macro.escape(&1),
552552+ quote do
553553+ Validators.blob()
554554+ end}
555555+ )
556556+ end
557557+558558+ defp field_to_schema(%{type: "bytes"} = field, _nsid) do
559559+ field
560560+ |> Map.take([:maxLength, :minLength])
561561+ |> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end)
562562+ |> Validators.bytes()
563563+ |> then(
564564+ &{Macro.escape(&1),
565565+ quote do
566566+ Validators.bytes()
567567+ end}
568568+ )
569569+ end
570570+571571+ defp field_to_schema(%{type: "cid-link"}, _nsid) do
572572+ Validators.cid_link()
573573+ |> then(
574574+ &{Macro.escape(&1),
575575+ quote do
576576+ Validators.cid_link()
577577+ end}
578578+ )
579579+ end
580580+581581+ # TODO: do i need to make sure these two deal with brands? Check objects in atp.tools
582582+ defp field_to_schema(%{type: "ref", ref: ref}, nsid) do
583583+ {nsid, fragment} =
584584+ nsid
585585+ |> Atex.NSID.expand_possible_fragment_shorthand(ref)
586586+ |> Atex.NSID.to_atom_with_fragment()
587587+588588+ fragment = Recase.to_snake(fragment)
589589+590590+ {
591591+ Macro.escape(Validators.lazy_ref(nsid, fragment)),
592592+ quote do
593593+ unquote(nsid).unquote(fragment)()
594594+ end
595595+ }
596596+ end
597597+598598+ defp field_to_schema(%{type: "union", refs: refs}, nsid) do
599599+ if refs == [] do
600600+ {quote do
601601+ {:oneof, []}
602602+ end, nil}
603603+ else
604604+ refs
605605+ |> Enum.map(fn ref ->
606606+ {nsid, fragment} =
607607+ nsid
608608+ |> Atex.NSID.expand_possible_fragment_shorthand(ref)
609609+ |> Atex.NSID.to_atom_with_fragment()
610610+611611+ fragment = Recase.to_snake(fragment)
612612+613613+ {
614614+ Macro.escape(Validators.lazy_ref(nsid, fragment)),
615615+ quote do
616616+ unquote(nsid).unquote(fragment)()
617617+ end
618618+ }
619619+ end)
620620+ |> Enum.reduce({[], []}, fn {quoted_schema, quoted_type}, {schemas, types} ->
621621+ {[quoted_schema | schemas], [quoted_type | types]}
622622+ end)
623623+ |> then(fn {schemas, types} ->
624624+ {quote do
625625+ {:oneof, unquote(schemas)}
626626+ end,
627627+ quote do
628628+ unquote(join_with_pipe(types))
629629+ end}
630630+ end)
631631+ end
632632+ end
633633+634634+ # TODO: apparently should be a data object, not a primitive?
635635+ defp field_to_schema(%{type: "unknown"}, _nsid) do
636636+ {:any,
637637+ quote do
638638+ term()
639639+ end}
640640+ end
641641+642642+ defp field_to_schema(_field_def, _nsid), do: {nil, nil}
643643+644644+ defp maybe_default(schema, field) do
645645+ if field[:default] != nil,
646646+ do: {schema, {:default, field.default}},
647647+ else: schema
648648+ end
649649+650650+ defp const_or_enum(field), do: const(field) || enum(field)
651651+652652+ defp const(%{const: value}), do: {:literal, value}
653653+ defp const(_), do: nil
654654+655655+ defp enum(%{enum: values}), do: {:enum, values}
656656+ defp enum(_), do: nil
657657+658658+ defp atomise(x) when is_atom(x), do: x
659659+ defp atomise(x) when is_binary(x), do: String.to_atom(x)
660660+661661+ defp join_with_pipe(list) when is_list(list) do
662662+ [piped] = do_join_with_pipe(list)
663663+ piped
664664+ end
665665+666666+ defp do_join_with_pipe([head]), do: [head]
667667+ defp do_join_with_pipe([head | tail]), do: [{:|, [], [head | do_join_with_pipe(tail)]}]
668668+ defp do_join_with_pipe([]), do: []
669669+end
+57
lib/atex/nsid.ex
···11+defmodule Atex.NSID do
22+ @re ~r/^[a-zA-Z](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?:\.[a-zA-Z](?:[a-zA-Z0-9]{0,62})?)$/
33+ # TODO: regex with support for fragment
44+55+ @spec re() :: Regex.t()
66+ def re, do: @re
77+88+ @spec match?(String.t()) :: boolean()
99+ def match?(value), do: Regex.match?(@re, value)
1010+1111+ # TODO: methods for fetching the authority and name from a nsid.
1212+ # maybe stuff for fetching the repo that belongs to an authority
1313+1414+ @spec to_atom(String.t()) :: atom()
1515+ def to_atom(nsid, fully_qualify \\ true) do
1616+ nsid
1717+ |> String.split(".")
1818+ |> Enum.map(&Recase.to_pascal/1)
1919+ |> then(fn parts ->
2020+ if fully_qualify do
2121+ ["Elixir" | parts]
2222+ else
2323+ parts
2424+ end
2525+ end)
2626+ |> Enum.join(".")
2727+ |> String.to_atom()
2828+ end
2929+3030+ @spec to_atom_with_fragment(String.t()) :: {atom(), atom()}
3131+ def to_atom_with_fragment(nsid) do
3232+ if !String.contains?(nsid, "#") do
3333+ {to_atom(nsid), :main}
3434+ else
3535+ [nsid, fragment] = String.split(nsid, "#")
3636+ {to_atom(nsid), String.to_atom(fragment)}
3737+ end
3838+ end
3939+4040+ @spec expand_possible_fragment_shorthand(String.t(), String.t()) :: String.t()
4141+ def expand_possible_fragment_shorthand(main_nsid, possible_fragment) do
4242+ if String.starts_with?(possible_fragment, "#") do
4343+ main_nsid <> possible_fragment
4444+ else
4545+ possible_fragment
4646+ end
4747+ end
4848+4949+ @spec canonical_name(String.t(), String.t()) :: String.t()
5050+ def canonical_name(nsid, fragment) do
5151+ if fragment == "main" do
5252+ nsid
5353+ else
5454+ "#{nsid}##{fragment}"
5555+ end
5656+ end
5757+end
+127
lib/atex/oauth/cache.ex
···11+defmodule Atex.OAuth.Cache do
22+ @moduledoc """
33+ TTL cache for OAuth authorization server information.
44+55+ This module manages two separate ConCache instances:
66+ - Authorization server cache (stores PDS -> authz server mappings)
77+ - Authorization metadata cache (stores authz server -> metadata mappings)
88+99+ Both caches use a 1-hour TTL to reduce load on third-party PDSs.
1010+ """
1111+1212+ use Supervisor
1313+1414+ @authz_server_cache :oauth_authz_server_cache
1515+ @authz_metadata_cache :oauth_authz_metadata_cache
1616+ @ttl_ms :timer.hours(1)
1717+1818+ @doc """
1919+ Starts the OAuth cache supervisor.
2020+ """
2121+ def start_link(opts) do
2222+ Supervisor.start_link(__MODULE__, opts, name: __MODULE__)
2323+ end
2424+2525+ @impl Supervisor
2626+ def init(_opts) do
2727+ children = [
2828+ Supervisor.child_spec(
2929+ {ConCache,
3030+ [
3131+ name: @authz_server_cache,
3232+ ttl_check_interval: :timer.minutes(5),
3333+ global_ttl: @ttl_ms
3434+ ]},
3535+ id: :authz_server_cache
3636+ ),
3737+ Supervisor.child_spec(
3838+ {ConCache,
3939+ [
4040+ name: @authz_metadata_cache,
4141+ ttl_check_interval: :timer.seconds(30),
4242+ global_ttl: @ttl_ms
4343+ ]},
4444+ id: :authz_metadata_cache
4545+ )
4646+ ]
4747+4848+ Supervisor.init(children, strategy: :one_for_one)
4949+ end
5050+5151+ @doc """
5252+ Get authorization server from cache.
5353+5454+ ## Parameters
5555+5656+ - `pds_host` - Base URL of the PDS (e.g., "https://bsky.social")
5757+5858+ ## Returns
5959+6060+ - `{:ok, authorization_server}` - Successfully retrieved from cache
6161+ - `{:error, :not_found}` - Not present in cache
6262+ """
6363+ @spec get_authorization_server(String.t()) :: {:ok, String.t()} | {:error, :not_found}
6464+ def get_authorization_server(pds_host) do
6565+ case ConCache.get(@authz_server_cache, pds_host) do
6666+ nil -> {:error, :not_found}
6767+ value -> {:ok, value}
6868+ end
6969+ end
7070+7171+ @doc """
7272+ Store authorization server in cache.
7373+7474+ ## Parameters
7575+7676+ - `pds_host` - Base URL of the PDS
7777+ - `authorization_server` - Authorization server URL to cache
7878+7979+ ## Returns
8080+8181+ - `:ok`
8282+ """
8383+ @spec set_authorization_server(String.t(), String.t()) :: :ok
8484+ def set_authorization_server(pds_host, authorization_server) do
8585+ ConCache.put(@authz_server_cache, pds_host, authorization_server)
8686+ :ok
8787+ end
8888+8989+ @doc """
9090+ Get authorization server metadata from cache.
9191+9292+ ## Parameters
9393+9494+ - `issuer` - Authorization server issuer URL
9595+9696+ ## Returns
9797+9898+ - `{:ok, metadata}` - Successfully retrieved from cache
9999+ - `{:error, :not_found}` - Not present in cache
100100+ """
101101+ @spec get_authorization_server_metadata(String.t()) ::
102102+ {:ok, Atex.OAuth.authorization_metadata()} | {:error, :not_found}
103103+ def get_authorization_server_metadata(issuer) do
104104+ case ConCache.get(@authz_metadata_cache, issuer) do
105105+ nil -> {:error, :not_found}
106106+ value -> {:ok, value}
107107+ end
108108+ end
109109+110110+ @doc """
111111+ Store authorization server metadata in cache.
112112+113113+ ## Parameters
114114+115115+ - `issuer` - Authorization server issuer URL
116116+ - `metadata` - Authorization server metadata to cache
117117+118118+ ## Returns
119119+120120+ - `:ok`
121121+ """
122122+ @spec set_authorization_server_metadata(String.t(), Atex.OAuth.authorization_metadata()) :: :ok
123123+ def set_authorization_server_metadata(issuer, metadata) do
124124+ ConCache.put(@authz_metadata_cache, issuer, metadata)
125125+ :ok
126126+ end
127127+end
+25
lib/atex/oauth/error.ex
···11+defmodule Atex.OAuth.Error do
22+ @moduledoc """
33+ Exception raised by `Atex.OAuth.Plug` when errors occurred. When using the
44+ Plug, you should set up a `Plug.ErrorHandler` to gracefully catch these and
55+ give messages to the end user.
66+77+ This extesion has two fields: a human-readable `message` string, and an atom
88+ `reason` for each specific error.
99+1010+ ## Reasons
1111+1212+ - `:missing_handle` - The handle query parameter was not provided
1313+ - `:invalid_handle` - The provided handle could not be resolved
1414+ - `:authorization_url_failed` - Failed to create the authorization URL
1515+ - `:invalid_callback_request` - Missing or invalid state/code in callback
1616+ - `:authorization_server_metadata_failed` - Could not fetch authorization
1717+ server metadata
1818+ - `:token_validation_failed` - Failed to validate the authorization code or
1919+ token
2020+ - `:issuer_mismatch` - OAuth issuer does not match PDS authorization server
2121+ - `:session_store_failed` - OAuth succeeded but failed to store the session
2222+ """
2323+2424+ defexception [:message, :reason]
2525+end
+809
lib/atex/oauth/permission.ex
···11+defmodule Atex.OAuth.Permission do
22+ use TypedStruct
33+ import Kernel, except: [to_string: 1]
44+55+ @type t_tuple() :: {
66+ resource :: String.t(),
77+ positional :: String.t() | nil,
88+ parameters :: list({String.t(), String.t()})
99+ }
1010+1111+ @typep as_string() :: {:as_string, boolean()}
1212+ @type account_attr() :: :email | :repo
1313+ @type account_action() :: :read | :manage
1414+ @type account_opt() ::
1515+ {:attr, account_attr()} | {:action, account_action()} | as_string()
1616+1717+ @type repo_opt() ::
1818+ {:create, boolean()} | {:update, boolean()} | {:delete, boolean()} | as_string()
1919+2020+ @type rpc_opt() :: {:aud, String.t()} | {:inherit_aud, boolean()} | as_string()
2121+2222+ @type include_opt() :: {:aud, String.t()} | as_string()
2323+2424+ typedstruct enforce: true do
2525+ field :resource, String.t()
2626+ field :positional, String.t() | nil
2727+ # like a Keyword list but with a string instead of an atom
2828+ field :parameters, list({String.t(), String.t()}), enforce: false, default: []
2929+ end
3030+3131+ @doc """
3232+ Creates a new permission struct from a permission scope string.
3333+3434+ Parses an AT Protocol OAuth permission scope string and returns a structured
3535+ representation. Permission strings follow the format
3636+ `resource:positional?key=value&key2=value2`
3737+3838+ The positional parameter is resource-specific and may be omitted in some cases
3939+ (e.g., collection for `repo`, lxm for `rpc`, attr for `account`/`identity`,
4040+ accept for `blob`).
4141+4242+ See the [AT Protocol
4343+ documentation](https://atproto.com/specs/permission#scope-string-syntax) for
4444+ the full syntax and rules for permission scope strings.
4545+4646+ ## Parameters
4747+ - `string` - A permission scope string (e.g., "repo:app.example.profile")
4848+4949+ Returns `{:ok, permission}` if a valid scope string was given, otherwise it
5050+ will return `{:error, reason}`.
5151+5252+ ## Examples
5353+5454+ # Simple with just a positional
5555+ iex> Atex.OAuth.Permission.new("repo:app.example.profile")
5656+ {:ok, %Atex.OAuth.Permission{
5757+ resource: "repo",
5858+ positional: "app.example.profile",
5959+ parameters: []
6060+ }}
6161+6262+ # With parameters
6363+ iex> Atex.OAuth.Permission.new("repo?collection=app.example.profile&collection=app.example.post")
6464+ {:ok, %Atex.OAuth.Permission{
6565+ resource: "repo",
6666+ positional: nil,
6767+ parameters: [
6868+ {"collection", "app.example.profile"},
6969+ {"collection", "app.example.post"}
7070+ ]
7171+ }}
7272+7373+ # Positional with parameters
7474+ iex> Atex.OAuth.Permission.new("rpc:app.example.moderation.createReport?aud=*")
7575+ {:ok, %Atex.OAuth.Permission{
7676+ resource: "rpc",
7777+ positional: "app.example.moderation.createReport",
7878+ parameters: [{"aud", "*"}]
7979+ }}
8080+8181+ iex> Atex.OAuth.Permission.new("blob:*/*")
8282+ {:ok, %Atex.OAuth.Permission{
8383+ resource: "blob",
8484+ positional: "*/*",
8585+ parameters: []
8686+ }}
8787+8888+ # Invalid: resource without positional or parameters
8989+ iex> Atex.OAuth.Permission.new("resource")
9090+ {:error, :missing_positional_or_parameters}
9191+9292+ """
9393+ @spec new(String.t()) :: {:ok, t()} | {:error, reason :: atom()}
9494+ def new(string) do
9595+ case parse(string) do
9696+ {:ok, {resource, positional, parameters}} ->
9797+ {:ok, %__MODULE__{resource: resource, positional: positional, parameters: parameters}}
9898+9999+ err ->
100100+ err
101101+ end
102102+ end
103103+104104+ @doc """
105105+ Parses an AT Protocol permission scope string into its components.
106106+107107+ Returns a tuple containing the resource name, optional positional parameter,
108108+ and a list of key-value parameter pairs. This is a lower-level function
109109+ compared to `new/1`, returning the raw components instead of a struct.
110110+111111+ ## Parameters
112112+ - `string` - A permission scope string following the format
113113+ `resource:positional?key=value&key2=value2`
114114+115115+ Returns `{:ok, {resource, positional, parameters}}` if a valid scope string
116116+ was given, otherwise it will return `{:error, reason}`.
117117+118118+ ## Examples
119119+120120+ # Simple with just a positional
121121+ iex> Atex.OAuth.Permission.parse("repo:app.example.profile")
122122+ {:ok, {"repo", "app.example.profile", []}}
123123+124124+ # With parameters
125125+ iex> Atex.OAuth.Permission.parse("repo?collection=app.example.profile&collection=app.example.post")
126126+ {:ok, {
127127+ "repo",
128128+ nil,
129129+ [
130130+ {"collection", "app.example.profile"},
131131+ {"collection", "app.example.post"}
132132+ ]
133133+ }}
134134+135135+ # Positional with parameters
136136+ iex> Atex.OAuth.Permission.parse("rpc:app.example.moderation.createReport?aud=*")
137137+ {:ok, {"rpc", "app.example.moderation.createReport", [{"aud", "*"}]}}
138138+139139+ iex> Atex.OAuth.Permission.parse("blob:*/*")
140140+ {:ok, {"blob", "*/*", []}}
141141+142142+ # Invalid: resource without positional or parameters
143143+ iex> Atex.OAuth.Permission.parse("resource")
144144+ {:error, :missing_positional_or_parameters}
145145+146146+ """
147147+ @spec parse(String.t()) ::
148148+ {:ok, t_tuple()}
149149+ | {:error, reason :: atom()}
150150+ def parse(string) do
151151+ case String.split(string, "?", parts: 2) do
152152+ [resource_part] ->
153153+ parse_resource_and_positional(resource_part)
154154+155155+ # Empty parameter string is treated as absent
156156+ [resource_part, ""] ->
157157+ parse_resource_and_positional(resource_part)
158158+159159+ [resource_part, params_part] ->
160160+ params_part
161161+ |> parse_parameters()
162162+ |> then(&parse_resource_and_positional(resource_part, &1))
163163+ end
164164+ end
165165+166166+ @spec parse_resource_and_positional(String.t(), list({String.t(), String.t()})) ::
167167+ {:ok, t_tuple()} | {:error, reason :: atom()}
168168+ defp parse_resource_and_positional(resource_part, parameters \\ []) do
169169+ case String.split(resource_part, ":", parts: 2) do
170170+ [resource_name, positional] ->
171171+ {:ok, {resource_name, positional, parameters}}
172172+173173+ [resource_name] ->
174174+ if parameters == [] do
175175+ {:error, :missing_positional_or_parameters}
176176+ else
177177+ {:ok, {resource_name, nil, parameters}}
178178+ end
179179+ end
180180+ end
181181+182182+ @spec parse_parameters(String.t()) :: list({String.t(), String.t()})
183183+ defp parse_parameters(params_string) do
184184+ params_string
185185+ |> String.split("&")
186186+ |> Enum.map(fn param ->
187187+ case String.split(param, "=", parts: 2) do
188188+ [key, value] -> {key, URI.decode(value)}
189189+ [key] -> {key, ""}
190190+ end
191191+ end)
192192+ end
193193+194194+ @doc """
195195+ Converts a permission struct back into its scope string representation.
196196+197197+ This is the inverse operation of `new/1`, converting a structured permission
198198+ back into the AT Protocol OAuth scope string format. The resulting string
199199+ can be used directly as an OAuth scope parameter.
200200+201201+ Values in `parameters` are automatically URL-encoded as needed (e.g., `#` becomes `%23`).
202202+203203+ ## Parameters
204204+ - `struct` - An `%Atex.OAuth.Permission{}` struct
205205+206206+ Returns a permission scope string.
207207+208208+ ## Examples
209209+210210+ # Simple with just a positional
211211+ iex> perm = %Atex.OAuth.Permission{
212212+ ...> resource: "repo",
213213+ ...> positional: "app.example.profile",
214214+ ...> parameters: []
215215+ ...> }
216216+ iex> Atex.OAuth.Permission.to_string(perm)
217217+ "repo:app.example.profile"
218218+219219+ # With parameters
220220+ iex> perm = %Atex.OAuth.Permission{
221221+ ...> resource: "repo",
222222+ ...> positional: nil,
223223+ ...> parameters: [
224224+ ...> {"collection", "app.example.profile"},
225225+ ...> {"collection", "app.example.post"}
226226+ ...> ]
227227+ ...> }
228228+ iex> Atex.OAuth.Permission.to_string(perm)
229229+ "repo?collection=app.example.profile&collection=app.example.post"
230230+231231+ # Positional with parameters
232232+ iex> perm = %Atex.OAuth.Permission{
233233+ ...> resource: "rpc",
234234+ ...> positional: "app.example.moderation.createReport",
235235+ ...> parameters: [{"aud", "*"}]
236236+ ...> }
237237+ iex> Atex.OAuth.Permission.to_string(perm)
238238+ "rpc:app.example.moderation.createReport?aud=*"
239239+240240+ iex> perm = %Atex.OAuth.Permission{
241241+ ...> resource: "blob",
242242+ ...> positional: "*/*",
243243+ ...> parameters: []
244244+ ...> }
245245+ iex> Atex.OAuth.Permission.to_string(perm)
246246+ "blob:*/*"
247247+248248+ # Works via String.Chars protocol
249249+ iex> perm = %Atex.OAuth.Permission{
250250+ ...> resource: "account",
251251+ ...> positional: "email",
252252+ ...> parameters: []
253253+ ...> }
254254+ iex> to_string(perm)
255255+ "account:email"
256256+257257+ """
258258+ @spec to_string(t()) :: String.t()
259259+ def to_string(%__MODULE__{} = struct) do
260260+ positional_part = if struct.positional, do: ":#{struct.positional}", else: ""
261261+ parameters_part = stringify_parameters(struct.parameters)
262262+263263+ struct.resource <> positional_part <> parameters_part
264264+ end
265265+266266+ @spec stringify_parameters(list({String.t(), String.t()})) :: String.t()
267267+ defp stringify_parameters([]), do: ""
268268+269269+ defp stringify_parameters(params) do
270270+ params
271271+ |> Enum.map(fn {key, value} -> "#{key}=#{encode_param_value(value)}" end)
272272+ |> Enum.join("&")
273273+ |> then(&"?#{&1}")
274274+ end
275275+276276+ # Encode parameter values for OAuth scope strings
277277+ # Preserves unreserved characters (A-Z, a-z, 0-9, -, ., _, ~) and common scope characters (*, :, /)
278278+ # Encodes reserved characters like # as %23
279279+ @spec encode_param_value(String.t()) :: String.t()
280280+ defp encode_param_value(value) do
281281+ URI.encode(value, fn char ->
282282+ URI.char_unreserved?(char) or char in [?*, ?:, ?/]
283283+ end)
284284+ end
285285+286286+ @doc """
287287+ Creates an account permission for controlling PDS account hosting details.
288288+289289+ Controls access to private account information such as email address and
290290+ repository import capabilities. These permissions cannot be included in
291291+ permission sets and must be requested directly by client apps.
292292+293293+ See the [AT Protocol documentation](https://atproto.com/specs/permission#account)
294294+ for more information.
295295+296296+ ## Options
297297+ - `:attr` (required) - A component of account configuration. Must be `:email`
298298+ or `:repo`.
299299+ - `:action` (optional) - Degree of control. Can be `:read` or `:manage`.
300300+ Defaults to `:read`.
301301+ - `:as_string` (optional) - If `true` (default), returns a scope string,
302302+ otherwise returns a Permission struct.
303303+304304+ If `:as_string` is true a scope string is returned, otherwise the underlying
305305+ Permission struct is returned.
306306+307307+ ## Examples
308308+309309+ # Read account email (default action, as string)
310310+ iex> Atex.OAuth.Permission.account(attr: :email)
311311+ "account:email"
312312+313313+ # Read account email (as struct)
314314+ iex> Atex.OAuth.Permission.account(attr: :email, as_string: false)
315315+ %Atex.OAuth.Permission{
316316+ resource: "account",
317317+ positional: "email",
318318+ parameters: []
319319+ }
320320+321321+ # Read account email (explicit action)
322322+ iex> Atex.OAuth.Permission.account(attr: :email, action: :read)
323323+ "account:email?action=read"
324324+325325+ # Manage account email
326326+ iex> Atex.OAuth.Permission.account(attr: :email, action: :manage)
327327+ "account:email?action=manage"
328328+329329+ # Import repo
330330+ iex> Atex.OAuth.Permission.account(attr: :repo, action: :manage)
331331+ "account:repo?action=manage"
332332+333333+ """
334334+ @spec account(list(account_opt())) :: t() | String.t()
335335+ def account(opts \\ []) do
336336+ opts = Keyword.validate!(opts, attr: nil, action: nil, as_string: true)
337337+ attr = Keyword.get(opts, :attr)
338338+ action = Keyword.get(opts, :action)
339339+ as_string = Keyword.get(opts, :as_string)
340340+341341+ cond do
342342+ is_nil(attr) ->
343343+ raise ArgumentError, "option `:attr` must be provided."
344344+345345+ attr not in [:email, :repo] ->
346346+ raise ArgumentError, "option `:attr` must be `:email` or `:repo`."
347347+348348+ action not in [nil, :read, :manage] ->
349349+ raise ArgumentError, "option `:action` must be `:read`, `:manage`, or `nil`."
350350+351351+ true ->
352352+ struct = %__MODULE__{
353353+ resource: "account",
354354+ positional: Atom.to_string(attr),
355355+ parameters: if(!is_nil(action), do: [{"action", Atom.to_string(action)}], else: [])
356356+ }
357357+358358+ if as_string, do: to_string(struct), else: struct
359359+ end
360360+ end
361361+362362+ @doc """
363363+ Creates a blob permission for uploading media files to PDS.
364364+365365+ Controls the ability to upload blobs (media files) to the PDS. Permissions can
366366+ be restricted by MIME type patterns.
367367+368368+ See the [AT Protocol documentation](https://atproto.com/specs/permission#blob)
369369+ for more information.
370370+371371+ <!-- TODO: When permission sets are supported, add the note from the docs about this not being allowed in permisison sets. -->
372372+373373+ ## Parameters
374374+ - `accept` - A single MIME type string or list of MIME type strings/patterns.
375375+ Supports glob patterns like `"*/*"` or `"video/*"`.
376376+ - `opts` - Keyword list of options.
377377+378378+ ## Options
379379+ - `:as_string` (optional) - If `true` (default), returns a scope string, otherwise
380380+ returns a Permission struct.
381381+382382+ If `:as_string` is true a scope string is returned, otherwise the underlying
383383+ Permission struct is returned.
384384+385385+ ## Examples
386386+387387+ # Upload any type of blob
388388+ iex> Atex.OAuth.Permission.blob("*/*")
389389+ "blob:*/*"
390390+391391+ # Only images
392392+ iex> Atex.OAuth.Permission.blob("image/*", as_string: false)
393393+ %Atex.OAuth.Permission{
394394+ resource: "blob",
395395+ positional: "image/*",
396396+ parameters: []
397397+ }
398398+399399+ # Multiple mimetypes
400400+ iex> Atex.OAuth.Permission.blob(["video/*", "text/html"])
401401+ "blob?accept=video/*&accept=text/html"
402402+403403+ # Multiple more specific mimetypes
404404+ iex> Atex.OAuth.Permission.blob(["image/png", "image/jpeg"], as_string: false)
405405+ %Atex.OAuth.Permission{
406406+ resource: "blob",
407407+ positional: nil,
408408+ parameters: [{"accept", "image/png"}, {"accept", "image/jpeg"}]
409409+ }
410410+411411+ """
412412+ # TODO: should probably validate that these at least look like mimetypes (~r"^.+/.+$")
413413+ @spec blob(String.t() | list(String.t()), list(as_string())) :: t() | String.t()
414414+ def blob(accept, opts \\ [])
415415+416416+ def blob(accept, opts) when is_binary(accept) do
417417+ opts = Keyword.validate!(opts, as_string: true)
418418+ as_string = Keyword.get(opts, :as_string)
419419+ struct = %__MODULE__{resource: "blob", positional: accept}
420420+ if as_string, do: to_string(struct), else: struct
421421+ end
422422+423423+ def blob(accept, opts) when is_list(accept) do
424424+ opts = Keyword.validate!(opts, as_string: true)
425425+ as_string = Keyword.get(opts, :as_string)
426426+427427+ struct = %__MODULE__{
428428+ resource: "blob",
429429+ positional: nil,
430430+ parameters: Enum.map(accept, &{"accept", &1})
431431+ }
432432+433433+ if as_string, do: to_string(struct), else: struct
434434+ end
435435+436436+ @doc """
437437+ Creates an identity permission for controlling network identity.
438438+439439+ Controls access to the account's DID document and handle. Note that the PDS
440440+ might not be able to facilitate identity changes if it does not have control
441441+ over the DID document (e.g., when using `did:web`).
442442+443443+ <!-- TODO: same thing about not allowed in permission sets. -->
444444+445445+ See the [AT Protocol
446446+ documentation](https://atproto.com/specs/permission#identity) for more
447447+ information.
448448+449449+ ## Parameters
450450+ - `attr` - An aspect or component of identity. Must be `:handle` or `:*`
451451+ (wildcard).
452452+ - `opts` - Keyword list of options.
453453+454454+ ## Options
455455+ - `:as_string` (optional) - If `true` (default), returns a scope string,
456456+ otherwise returns a Permission struct.
457457+458458+ If `:as_string` is true a scope string is returned, otherwise the underlying
459459+ Permission struct is returned.
460460+461461+ ## Examples
462462+463463+ # Update account handle (as string)
464464+ iex> Atex.OAuth.Permission.identity(:handle)
465465+ "identity:handle"
466466+467467+ # Full identity control (as struct)
468468+ iex> Atex.OAuth.Permission.identity(:*, as_string: false)
469469+ %Atex.OAuth.Permission{
470470+ resource: "identity",
471471+ positional: "*",
472472+ parameters: []
473473+ }
474474+475475+ """
476476+ @spec identity(:handle | :*, list(as_string())) :: t() | String.t()
477477+ def identity(attr, opts \\ []) when attr in [:handle, :*] do
478478+ opts = Keyword.validate!(opts, as_string: true)
479479+ as_string = Keyword.get(opts, :as_string)
480480+481481+ struct = %__MODULE__{
482482+ resource: "identity",
483483+ positional: Atom.to_string(attr)
484484+ }
485485+486486+ if as_string, do: to_string(struct), else: struct
487487+ end
488488+489489+ @doc """
490490+ Creates a repo permission for write access to records in the account's public
491491+ repository.
492492+493493+ Controls write access to specific record types (collections) with optional
494494+ restrictions on the types of operations allowed (create, update, delete).
495495+496496+ When no options are provided, all operations are permitted. When any action
497497+ option is explicitly set, only the actions set to `true` are enabled. This
498498+ allows for precise control over permissions.
499499+500500+ See the [AT Protocol documentation](https://atproto.com/specs/permission#repo)
501501+ for more information.
502502+503503+ ## Parameters
504504+ - `collection_or_collections` - A single collection NSID string or list of
505505+ collection NSIDs. Use `"*"` for wildcard access to all record types (not
506506+ allowed in permission sets).
507507+ - `options` - Keyword list to restrict operations. If omitted, all operations
508508+ are allowed. If any action is specified, only explicitly enabled actions are
509509+ permitted.
510510+511511+ ## Options
512512+ - `:create` - Allow creating new records.
513513+ - `:update` - Allow updating existing records.
514514+ - `:delete` - Allow deleting records.
515515+ - `:as_string` (optional) - If `true` (default), returns a scope string,
516516+ otherwise returns a Permission struct.
517517+518518+ If `:as_string` is true a scope string is returned, otherwise the underlying
519519+ Permission struct is returned.
520520+521521+ ## Examples
522522+523523+ # Full permission on a single record type (all actions enabled, actions omitted)
524524+ iex> Atex.OAuth.Permission.repo("app.example.profile")
525525+ "repo:app.example.profile"
526526+527527+ # Create only permission (other actions implicitly disabled)
528528+ iex> Atex.OAuth.Permission.repo("app.example.post", create: true, as_string: false)
529529+ %Atex.OAuth.Permission{
530530+ resource: "repo",
531531+ positional: "app.example.post",
532532+ parameters: [{"action", "create"}]
533533+ }
534534+535535+ # Delete only permission
536536+ iex> Atex.OAuth.Permission.repo("app.example.like", delete: true)
537537+ "repo:app.example.like?action=delete"
538538+539539+ # Create and update only, delete implicitly disabled
540540+ iex> Atex.OAuth.Permission.repo("app.example.repost", create: true, update: true)
541541+ "repo:app.example.repost?action=update&action=create"
542542+543543+ # Multiple collections with full permissions (no options provided, actions omitted)
544544+ iex> Atex.OAuth.Permission.repo(["app.example.profile", "app.example.post"])
545545+ "repo?collection=app.example.profile&collection=app.example.post"
546546+547547+ # Multiple collections with only update permission (as struct)
548548+ iex> Atex.OAuth.Permission.repo(["app.example.like", "app.example.repost"], update: true, as_string: false)
549549+ %Atex.OAuth.Permission{
550550+ resource: "repo",
551551+ positional: nil,
552552+ parameters: [
553553+ {"collection", "app.example.like"},
554554+ {"collection", "app.example.repost"},
555555+ {"action", "update"}
556556+ ]
557557+ }
558558+559559+ # Wildcard permission (all record types, all actions enabled, actions omitted)
560560+ iex> Atex.OAuth.Permission.repo("*")
561561+ "repo:*"
562562+ """
563563+ @spec repo(String.t() | list(String.t()), list(repo_opt())) :: t() | String.t()
564564+ def repo(collection_or_collections, actions \\ [create: true, update: true, delete: true])
565565+566566+ def repo(_collection, []),
567567+ do:
568568+ raise(
569569+ ArgumentError,
570570+ ":actions must not be an empty list. If you want to have all actions enabled, either set them explicitly or remove the empty list argument."
571571+ )
572572+573573+ def repo(collection, actions) when is_binary(collection), do: repo([collection], actions)
574574+575575+ def repo(collections, actions) when is_list(collections) do
576576+ actions =
577577+ Keyword.validate!(actions, [:create, :update, :delete, as_string: true])
578578+579579+ # Check if any action keys were explicitly provided
580580+ has_explicit_actions =
581581+ Keyword.has_key?(actions, :create) ||
582582+ Keyword.has_key?(actions, :update) ||
583583+ Keyword.has_key?(actions, :delete)
584584+585585+ # If no action keys provided, default all to true; otherwise use explicit values
586586+ create = if has_explicit_actions, do: Keyword.get(actions, :create, false), else: true
587587+ update = if has_explicit_actions, do: Keyword.get(actions, :update, false), else: true
588588+ delete = if has_explicit_actions, do: Keyword.get(actions, :delete, false), else: true
589589+ all_actions_true = create && update && delete
590590+591591+ as_string = Keyword.get(actions, :as_string)
592592+ singular_collection = length(collections) == 1
593593+ collection_parameters = Enum.map(collections, &{"collection", &1})
594594+595595+ parameters =
596596+ []
597597+ |> add_repo_param(:create, create, all_actions_true)
598598+ |> add_repo_param(:update, update, all_actions_true)
599599+ |> add_repo_param(:delete, delete, all_actions_true)
600600+ |> add_repo_param(:collections, collection_parameters)
601601+602602+ struct = %__MODULE__{
603603+ resource: "repo",
604604+ positional: if(singular_collection, do: hd(collections)),
605605+ parameters: parameters
606606+ }
607607+608608+ if as_string, do: to_string(struct), else: struct
609609+ end
610610+611611+ # When all actions are true, omit them
612612+ defp add_repo_param(list, _type, _value, true), do: list
613613+ # Otherwise add them in
614614+ defp add_repo_param(list, :create, true, false), do: [{"action", "create"} | list]
615615+ defp add_repo_param(list, :update, true, false), do: [{"action", "update"} | list]
616616+ defp add_repo_param(list, :delete, true, false), do: [{"action", "delete"} | list]
617617+618618+ # Catch-all for 4-arity version (must be before 3-arity)
619619+ defp add_repo_param(list, _type, _value, _all_true), do: list
620620+621621+ defp add_repo_param(list, :collections, [_ | [_ | _]] = collections),
622622+ do: Enum.concat(collections, list)
623623+624624+ defp add_repo_param(list, _type, _value), do: list
625625+626626+ @doc """
627627+ Creates an RPC permission for authenticated API requests to remote services.
628628+629629+ The permission is parameterised by the remote endpoint (`lxm`, short for
630630+ "Lexicon Method") and the identity of the remote service (the audience,
631631+ `aud`). Permissions must be restricted by at least one of these parameters.
632632+633633+ See the [AT Protocol documentation](https://atproto.com/specs/permission#rpc)
634634+ for more information.
635635+636636+ ## Parameters
637637+ - `lxm` - A single NSID string or list of NSID strings representing API
638638+ endpoints. Use `"*"` for wildcard access to all endpoints.
639639+ - `opts` - Keyword list of options.
640640+641641+ ## Options
642642+ - `:aud` (semi-required) - Audience of API requests as a DID service
643643+ reference (e.g., `"did:web:api.example.com#srvtype"`). Supports wildcard
644644+ (`"*"`).
645645+ - `:inherit_aud` (optional) - If `true`, the `aud` value will be inherited
646646+ from permission set invocation context. Only used inside permission sets.
647647+ - `:as_string` (optional) - If `true` (default), returns a scope string,
648648+ otherwise returns a Permission struct.
649649+650650+ > #### Note {: .info}
651651+ >
652652+ > `aud` and `lxm` cannot both be wildcard. The permission must be restricted
653653+ > by at least one of them.
654654+655655+ If `:as_string` is true a scope string is returned, otherwise the underlying
656656+ Permission struct is returned.
657657+658658+ ## Examples
659659+660660+ # Single endpoint with wildcard audience (as string)
661661+ iex> Atex.OAuth.Permission.rpc("app.example.moderation.createReport", aud: "*")
662662+ "rpc:app.example.moderation.createReport?aud=*"
663663+664664+ # Multiple endpoints with specific service (as struct)
665665+ iex> Atex.OAuth.Permission.rpc(
666666+ ...> ["app.example.getFeed", "app.example.getProfile"],
667667+ ...> aud: "did:web:api.example.com#svc_appview",
668668+ ...> as_string: false
669669+ ...> )
670670+ %Atex.OAuth.Permission{
671671+ resource: "rpc",
672672+ positional: nil,
673673+ parameters: [
674674+ {"aud", "did:web:api.example.com#svc_appview"},
675675+ {"lxm", "app.example.getFeed"},
676676+ {"lxm", "app.example.getProfile"}
677677+ ]
678678+ }
679679+680680+ # Wildcard method with specific service
681681+ iex> Atex.OAuth.Permission.rpc("*", aud: "did:web:api.example.com#svc_appview")
682682+ "rpc:*?aud=did:web:api.example.com%23svc_appview"
683683+684684+ # Single endpoint with inherited audience (for permission sets)
685685+ iex> Atex.OAuth.Permission.rpc("app.example.getPreferences", inherit_aud: true)
686686+ "rpc:app.example.getPreferences?inheritAud=true"
687687+688688+ """
689689+ @spec rpc(String.t() | list(String.t()), list(rpc_opt())) :: t() | String.t()
690690+ def rpc(lxm_or_lxms, opts \\ [])
691691+ def rpc(lxm, opts) when is_binary(lxm), do: rpc([lxm], opts)
692692+693693+ def rpc(lxms, opts) when is_list(lxms) do
694694+ opts = Keyword.validate!(opts, aud: nil, inherit_aud: false, as_string: true)
695695+ aud = Keyword.get(opts, :aud)
696696+ inherit_aud = Keyword.get(opts, :inherit_aud)
697697+ as_string = Keyword.get(opts, :as_string)
698698+699699+ # Validation: must have at least one of aud or inherit_aud
700700+ cond do
701701+ is_nil(aud) && !inherit_aud ->
702702+ raise ArgumentError,
703703+ "RPC permissions must specify either `:aud` or `:inheritAud` option."
704704+705705+ !is_nil(aud) && inherit_aud ->
706706+ raise ArgumentError,
707707+ "RPC permissions cannot specify both `:aud` and `:inheritAud` options."
708708+709709+ # Both lxm and aud cannot be wildcard
710710+ length(lxms) == 1 && hd(lxms) == "*" && aud == "*" ->
711711+ raise ArgumentError, "RPC permissions cannot have both wildcard `lxm` and wildcard `aud`."
712712+713713+ true ->
714714+ singular_lxm = length(lxms) == 1
715715+ lxm_parameters = Enum.map(lxms, &{"lxm", &1})
716716+717717+ parameters =
718718+ cond do
719719+ inherit_aud && singular_lxm ->
720720+ [{"inheritAud", "true"}]
721721+722722+ inherit_aud ->
723723+ [{"inheritAud", "true"} | lxm_parameters]
724724+725725+ singular_lxm ->
726726+ [{"aud", aud}]
727727+728728+ true ->
729729+ [{"aud", aud} | lxm_parameters]
730730+ end
731731+732732+ struct = %__MODULE__{
733733+ resource: "rpc",
734734+ positional: if(singular_lxm, do: hd(lxms)),
735735+ parameters: parameters
736736+ }
737737+738738+ if as_string, do: to_string(struct), else: struct
739739+ end
740740+ end
741741+742742+ @doc """
743743+ Creates an include permission for referencing a permission set.
744744+745745+ Permission sets are Lexicon schemas that bundle together multiple permissions
746746+ under a single NSID. This allows developers to request a group of related
747747+ permissions with a single scope string, improving user experience by reducing
748748+ the number of individual permissions that need to be reviewed.
749749+750750+ The `nsid` parameter is required and must be a valid NSID that resolves to a
751751+ permission set Lexicon schema. An optional `aud` parameter can be used to specify
752752+ the audience for any RPC permissions within the set that have `inheritAud: true`.
753753+754754+ See the [AT Protocol documentation](https://atproto.com/specs/permission#permission-sets)
755755+ for more information.
756756+757757+ ## Parameters
758758+ - `nsid` - The NSID of the permission set (e.g., "com.example.authBasicFeatures")
759759+ - `opts` - Keyword list of options.
760760+761761+ ## Options
762762+ - `:aud` (optional) - Audience of API requests as a DID service reference
763763+ (e.g., "did:web:api.example.com#srvtype"). Supports wildcard (`"*"`).
764764+ - `:as_string` (optional) - If `true` (default), returns a scope string,
765765+ otherwise returns a Permission struct.
766766+767767+ If `:as_string` is true a scope string is returned, otherwise the underlying
768768+ Permission struct is returned.
769769+770770+ ## Examples
771771+772772+ # Include a permission set (as string)
773773+ iex> Atex.OAuth.Permission.include("com.example.authBasicFeatures")
774774+ "include:com.example.authBasicFeatures"
775775+776776+ # Include a permission set with audience (as struct)
777777+ iex> Atex.OAuth.Permission.include("com.example.authFull", aud: "did:web:api.example.com#svc_chat", as_string: false)
778778+ %Atex.OAuth.Permission{
779779+ resource: "include",
780780+ positional: "com.example.authFull",
781781+ parameters: [{"aud", "did:web:api.example.com#svc_chat"}]
782782+ }
783783+784784+ # Include a permission set with wildcard audience
785785+ iex> Atex.OAuth.Permission.include("app.example.authFull", aud: "*")
786786+ "include:app.example.authFull?aud=*"
787787+788788+ """
789789+ @spec include(String.t(), list(include_opt())) :: t() | String.t()
790790+ def include(nsid, opts \\ []) do
791791+ opts = Keyword.validate!(opts, aud: nil, as_string: true)
792792+ aud = Keyword.get(opts, :aud)
793793+ as_string = Keyword.get(opts, :as_string)
794794+795795+ parameters = if !is_nil(aud), do: [{"aud", aud}], else: []
796796+797797+ struct = %__MODULE__{
798798+ resource: "include",
799799+ positional: nsid,
800800+ parameters: parameters
801801+ }
802802+803803+ if as_string, do: to_string(struct), else: struct
804804+ end
805805+end
806806+807807+defimpl String.Chars, for: Atex.OAuth.Permission do
808808+ def to_string(permission), do: Atex.OAuth.Permission.to_string(permission)
809809+end
+244
lib/atex/oauth/plug.ex
···11+defmodule Atex.OAuth.Plug do
22+ @moduledoc """
33+ Plug router for handling AT Protocol's OAuth flow.
44+55+ This module provides three endpoints:
66+77+ - `GET /login?handle=<handle>` - Initiates the OAuth authorization flow for a
88+ given handle
99+ - `GET /callback` - Handles the OAuth callback after user authorization
1010+ - `GET /client-metadata.json` - Serves the OAuth client metadata
1111+1212+ ## Usage
1313+1414+ This module requires `Plug.Session` to be in your pipeline, as well as
1515+ `secret_key_base` to have been set on your connections. Ideally it should be
1616+ routed to via `Plug.Router.forward/2`, under a route like "/oauth".
1717+1818+ The plug requires a `:callback` option that must be an MFA tuple (Module,
1919+ Function, Args). This callback is invoked after successful OAuth
2020+ authentication, receiving the connection with the authenticated session data.
2121+2222+ ## Error Handling
2323+2424+ `Atex.OAuth.Error` exceptions are raised when errors occur during the OAuth
2525+ flow (e.g. an invalid handle is provided, or validation failed). You should
2626+ implement a `Plug.ErrorHandler` to catch and handle these exceptions
2727+ gracefully.
2828+2929+ ## Example
3030+3131+ Example implementation showing how to set up the OAuth plug with proper
3232+ session handling, error handling, and a callback function.
3333+3434+ defmodule ExampleOAuthPlug do
3535+ use Plug.Router
3636+ use Plug.ErrorHandler
3737+3838+ plug :put_secret_key_base
3939+4040+ plug Plug.Session,
4141+ store: :cookie,
4242+ key: "atex-oauth",
4343+ signing_salt: "signing-salt"
4444+4545+ plug :match
4646+ plug :dispatch
4747+4848+ forward "/oauth", to: Atex.OAuth.Plug, init_opts: [callback: {__MODULE__, :oauth_callback, []}]
4949+5050+ def oauth_callback(conn) do
5151+ # Handle successful OAuth authentication
5252+ conn
5353+ |> put_resp_header("Location", "/dashboard")
5454+ |> resp(307, "")
5555+ |> send_resp()
5656+ end
5757+5858+ def put_secret_key_base(conn, _) do
5959+ put_in(
6060+ conn.secret_key_base,
6161+ "very long key base with at least 64 bytes"
6262+ )
6363+ end
6464+6565+ # Error handler for OAuth exceptions
6666+ @impl Plug.ErrorHandler
6767+ def handle_errors(conn, %{kind: :error, reason: %Atex.OAuth.Error{} = error, stack: _stack}) do
6868+ status = case error.reason do
6969+ reason when reason in [:missing_handle, :invalid_handle, :invalid_callback_request, :issuer_mismatch] -> 400
7070+ _ -> 500
7171+ end
7272+7373+ conn
7474+ |> put_resp_content_type("text/plain")
7575+ |> send_resp(status, error.message)
7676+ end
7777+7878+ # Fallback for other errors
7979+ def handle_errors(conn, %{kind: _kind, reason: _reason, stack: _stack}) do
8080+ send_resp(conn, conn.status, "Something went wrong")
8181+ end
8282+ end
8383+8484+ ## Session Storage
8585+8686+ After successful authentication, the plug stores these in the session:
8787+8888+ - `:tokens` - The access token response containing access_token,
8989+ refresh_token, did, and expires_at
9090+ - `:dpop_nonce` -
9191+ - `:dpop_key` - The DPoP JWK for generating DPoP proofs
9292+ """
9393+ require Logger
9494+ use Plug.Router
9595+ require Plug.Router
9696+ alias Atex.OAuth
9797+ alias Atex.{IdentityResolver, IdentityResolver.DIDDocument}
9898+9999+ @oauth_cookie_opts [path: "/", http_only: true, secure: true, same_site: "lax", max_age: 600]
100100+ @session_name :atex_session
101101+102102+ def init(opts) do
103103+ callback = Keyword.get(opts, :callback, nil)
104104+105105+ if !match?({_module, _function, _args}, callback) do
106106+ raise "expected callback to be a MFA tuple"
107107+ end
108108+109109+ opts
110110+ end
111111+112112+ def call(conn, opts) do
113113+ conn
114114+ |> put_private(:atex_oauth_opts, opts)
115115+ |> super(opts)
116116+ end
117117+118118+ plug :match
119119+ plug :dispatch
120120+121121+ get "/login" do
122122+ conn = fetch_query_params(conn)
123123+ handle = conn.query_params["handle"]
124124+125125+ if !handle do
126126+ raise Atex.OAuth.Error,
127127+ message: "Handle query parameter is required",
128128+ reason: :missing_handle
129129+ end
130130+131131+ case IdentityResolver.resolve(handle) do
132132+ {:ok, identity} ->
133133+ pds = DIDDocument.get_pds_endpoint(identity.document)
134134+ {:ok, authz_server} = OAuth.get_authorization_server(pds)
135135+ {:ok, authz_metadata} = OAuth.get_authorization_server_metadata(authz_server)
136136+ state = OAuth.create_nonce()
137137+ code_verifier = OAuth.create_nonce()
138138+139139+ case OAuth.create_authorization_url(
140140+ authz_metadata,
141141+ state,
142142+ code_verifier,
143143+ handle
144144+ ) do
145145+ {:ok, authz_url} ->
146146+ conn
147147+ |> put_resp_cookie("state", state, @oauth_cookie_opts)
148148+ |> put_resp_cookie("code_verifier", code_verifier, @oauth_cookie_opts)
149149+ |> put_resp_cookie("issuer", authz_metadata.issuer, @oauth_cookie_opts)
150150+ |> put_resp_header("location", authz_url)
151151+ |> send_resp(307, "")
152152+153153+ {:error, _err} ->
154154+ raise Atex.OAuth.Error,
155155+ message: "Failed to create authorization URL",
156156+ reason: :authorization_url_failed
157157+ end
158158+159159+ _err ->
160160+ raise Atex.OAuth.Error, message: "Invalid or unresolvable handle", reason: :invalid_handle
161161+ end
162162+ end
163163+164164+ get "/client-metadata.json" do
165165+ conn
166166+ |> put_resp_content_type("application/json")
167167+ |> send_resp(200, JSON.encode_to_iodata!(OAuth.create_client_metadata()))
168168+ end
169169+170170+ get "/callback" do
171171+ conn = conn |> fetch_query_params() |> fetch_session()
172172+ callback = Keyword.get(conn.private.atex_oauth_opts, :callback)
173173+ cookies = get_cookies(conn)
174174+ stored_state = cookies["state"]
175175+ stored_code_verifier = cookies["code_verifier"]
176176+ stored_issuer = cookies["issuer"]
177177+178178+ code = conn.query_params["code"]
179179+ state = conn.query_params["state"]
180180+181181+ if !stored_state || !stored_code_verifier || !stored_issuer || (!code || !state) ||
182182+ stored_state != state do
183183+ raise Atex.OAuth.Error,
184184+ message: "Invalid callback request: missing or mismatched state/code parameters",
185185+ reason: :invalid_callback_request
186186+ end
187187+188188+ with {:ok, authz_metadata} <- OAuth.get_authorization_server_metadata(stored_issuer),
189189+ dpop_key <- JOSE.JWK.generate_key({:ec, "P-256"}),
190190+ {:ok, tokens, nonce} <-
191191+ OAuth.validate_authorization_code(
192192+ authz_metadata,
193193+ dpop_key,
194194+ code,
195195+ stored_code_verifier
196196+ ),
197197+ {:ok, identity} <- IdentityResolver.resolve(tokens.did),
198198+ # Make sure pds' issuer matches the stored one (just in case)
199199+ pds <- DIDDocument.get_pds_endpoint(identity.document),
200200+ {:ok, authz_server} <- OAuth.get_authorization_server(pds),
201201+ true <- authz_server == stored_issuer do
202202+ session = %OAuth.Session{
203203+ iss: authz_server,
204204+ aud: pds,
205205+ sub: tokens.did,
206206+ access_token: tokens.access_token,
207207+ refresh_token: tokens.refresh_token,
208208+ expires_at: tokens.expires_at,
209209+ dpop_key: dpop_key,
210210+ dpop_nonce: nonce
211211+ }
212212+213213+ case OAuth.SessionStore.insert(session) do
214214+ :ok ->
215215+ conn =
216216+ conn
217217+ |> delete_resp_cookie("state", @oauth_cookie_opts)
218218+ |> delete_resp_cookie("code_verifier", @oauth_cookie_opts)
219219+ |> delete_resp_cookie("issuer", @oauth_cookie_opts)
220220+ |> put_session(@session_name, tokens.did)
221221+222222+ {mod, func, args} = callback
223223+ apply(mod, func, [conn | args])
224224+225225+ {:error, reason} ->
226226+ raise Atex.OAuth.Error,
227227+ message: "Failed to store OAuth session, reason: #{reason}",
228228+ reason: :session_store_failed
229229+ end
230230+ else
231231+ false ->
232232+ raise Atex.OAuth.Error,
233233+ message: "OAuth issuer does not match PDS' authorization server",
234234+ reason: :issuer_mismatch
235235+236236+ _err ->
237237+ raise Atex.OAuth.Error,
238238+ message: "Failed to validate authorization code or token",
239239+ reason: :token_validation_failed
240240+ end
241241+ end
242242+243243+ # TODO: logout route
244244+end
+50
lib/atex/oauth/session.ex
···11+defmodule Atex.OAuth.Session do
22+ @moduledoc """
33+ Struct representing an active OAuth session for an AT Protocol user.
44+55+ Contains all the necessary credentials and metadata to make authenticated
66+ requests to a user's PDS using OAuth with DPoP.
77+88+ ## Fields
99+1010+ - `:iss` - Authorization server issuer URL
1111+ - `:aud` - PDS endpoint URL (audience)
1212+ - `:sub` - User's DID (subject), used as the session key
1313+ - `:access_token` - OAuth access token for authenticating requests
1414+ - `:refresh_token` - OAuth refresh token for obtaining new access tokens
1515+ - `:expires_at` - When the current access token expires (NaiveDateTime in UTC)
1616+ - `:dpop_key` - DPoP signing key (Demonstrating Proof-of-Possession)
1717+ - `:dpop_nonce` - Server-provided nonce for DPoP proofs (optional, updated per-request)
1818+1919+ ## Usage
2020+2121+ Sessions are typically created during the OAuth flow and stored in a `SessionStore`.
2222+ They should not be created manually in most cases.
2323+2424+ session = %Atex.OAuth.Session{
2525+ iss: "https://bsky.social",
2626+ aud: "https://puffball.us-east.host.bsky.network",
2727+ sub: "did:plc:abc123",
2828+ access_token: "...",
2929+ refresh_token: "...",
3030+ expires_at: ~N[2026-01-04 12:00:00],
3131+ dpop_key: dpop_key,
3232+ dpop_nonce: "server-nonce"
3333+ }
3434+ """
3535+ use TypedStruct
3636+3737+ typedstruct enforce: true do
3838+ # Authz server issuer
3939+ field :iss, String.t()
4040+ # PDS endpoint
4141+ field :aud, String.t()
4242+ # User's DID
4343+ field :sub, String.t()
4444+ field :access_token, String.t()
4545+ field :refresh_token, String.t()
4646+ field :expires_at, NaiveDateTime.t()
4747+ field :dpop_key, JOSE.JWK.t()
4848+ field :dpop_nonce, String.t() | nil, enforce: false
4949+ end
5050+end
+121
lib/atex/oauth/session_store/dets.ex
···11+defmodule Atex.OAuth.SessionStore.DETS do
22+ @moduledoc """
33+ DETS implementation for `Atex.OAuth.SessionStore`.
44+55+ This is recommended for single-node production deployments, as sessions will
66+ persist on disk between application restarts. For more complex, multi-node
77+ deployments, consider making a custom implementation using Redis or some other
88+ distributed store.
99+1010+ ## Configuration
1111+1212+ By default the DETS file is stored at `priv/dets/atex_oauth_sessions.dets`
1313+ relative to where your application is running. You can configure the file path
1414+ in your `config.exs`:
1515+1616+ config :atex, Atex.OAuth.SessionStore.DETS,
1717+ file_path: "/var/lib/myapp/sessions.dets"
1818+1919+ Parent directories will be created as necessary if possible.
2020+ """
2121+2222+ alias Atex.OAuth.Session
2323+ require Logger
2424+ use Supervisor
2525+2626+ @behaviour Atex.OAuth.SessionStore
2727+ @table :atex_oauth_sessions
2828+ @default_file "priv/dets/atex_oauth_sessions.dets"
2929+3030+ def start_link(opts) do
3131+ Supervisor.start_link(__MODULE__, opts)
3232+ end
3333+3434+ @impl Supervisor
3535+ def init(_opts) do
3636+ dets_file =
3737+ case Application.get_env(:atex, __MODULE__, [])[:file_path] do
3838+ nil ->
3939+ @default_file
4040+4141+ path ->
4242+ path
4343+ end
4444+4545+ # Ensure parent directory exists
4646+ dets_file
4747+ |> Path.dirname()
4848+ |> File.mkdir_p!()
4949+5050+ case :dets.open_file(@table, file: String.to_charlist(dets_file), type: :set) do
5151+ {:ok, @table} ->
5252+ Logger.info("DETS session store opened: #{dets_file}")
5353+ Supervisor.init([], strategy: :one_for_one)
5454+5555+ {:error, reason} ->
5656+ Logger.error("Failed to open DETS file: #{inspect(reason)}")
5757+ raise "Failed to initialize DETS session store: #{inspect(reason)}"
5858+ end
5959+ end
6060+6161+ @doc """
6262+ Insert a session into the DETS table.
6363+6464+ Returns `:ok` on success, `{:error, reason}` if an unexpected error occurs.
6565+ """
6666+ @impl Atex.OAuth.SessionStore
6767+ @spec insert(String.t(), Session.t()) :: :ok | {:error, atom()}
6868+ def insert(key, session) do
6969+ case :dets.insert(@table, {key, session}) do
7070+ :ok ->
7171+ :ok
7272+7373+ {:error, reason} ->
7474+ Logger.error("DETS insert failed: #{inspect(reason)}")
7575+ {:error, reason}
7676+ end
7777+ end
7878+7979+ @doc """
8080+ Update a session in the DETS table.
8181+8282+ In DETS, this is the same as insert - it replaces the existing entry.
8383+ """
8484+ @impl Atex.OAuth.SessionStore
8585+ @spec update(String.t(), Session.t()) :: :ok | {:error, atom()}
8686+ def update(key, session) do
8787+ insert(key, session)
8888+ end
8989+9090+ @doc """
9191+ Retrieve a session from the DETS table.
9292+9393+ Returns `{:ok, session}` if found, `{:error, :not_found}` otherwise.
9494+ """
9595+ @impl Atex.OAuth.SessionStore
9696+ @spec get(String.t()) :: {:ok, Session.t()} | {:error, atom()}
9797+ def get(key) do
9898+ case :dets.lookup(@table, key) do
9999+ [{_key, session}] -> {:ok, session}
100100+ [] -> {:error, :not_found}
101101+ end
102102+ end
103103+104104+ @doc """
105105+ Delete a session from the DETS table.
106106+107107+ Returns `:ok` if deleted, `:noop` if the session didn't exist.
108108+ """
109109+ @impl Atex.OAuth.SessionStore
110110+ @spec delete(String.t()) :: :ok | :error | :noop
111111+ def delete(key) do
112112+ case get(key) do
113113+ {:ok, _session} ->
114114+ :dets.delete(@table, key)
115115+ :ok
116116+117117+ {:error, :not_found} ->
118118+ :noop
119119+ end
120120+ end
121121+end
+88
lib/atex/oauth/session_store/ets.ex
···11+defmodule Atex.OAuth.SessionStore.ETS do
22+ @moduledoc """
33+ In-memory, ETS implementation for `Atex.OAuth.SessionStore`.
44+55+ This is moreso intended for testing or some occasion where you want the
66+ session store to be volatile for some reason. It's recommended you use
77+ `Atex.OAuth.SessionStore.DETS` for single-node production deployments.
88+ """
99+1010+ alias Atex.OAuth.Session
1111+ require Logger
1212+ use Supervisor
1313+1414+ @behaviour Atex.OAuth.SessionStore
1515+ @table :atex_oauth_sessions
1616+1717+ def start_link(opts) do
1818+ Supervisor.start_link(__MODULE__, opts)
1919+ end
2020+2121+ @impl Supervisor
2222+ def init(_opts) do
2323+ :ets.new(@table, [:set, :public, :named_table])
2424+ Supervisor.init([], strategy: :one_for_one)
2525+ end
2626+2727+ @doc """
2828+ Insert a session into the ETS table.
2929+3030+ Returns `:ok` on success, `{:error, :ets}` if an unexpected error occurs.
3131+ """
3232+ @impl Atex.OAuth.SessionStore
3333+ @spec insert(String.t(), Session.t()) :: :ok | {:error, atom()}
3434+ def insert(key, session) do
3535+ try do
3636+ :ets.insert(@table, {key, session})
3737+ :ok
3838+ rescue
3939+ # Freak accidents can occur
4040+ e ->
4141+ Logger.error(Exception.format(:error, e, __STACKTRACE__))
4242+ {:error, :ets}
4343+ end
4444+ end
4545+4646+ @doc """
4747+ Update a session in the ETS table.
4848+4949+ In ETS, this is the same as insert - it replaces the existing entry.
5050+ """
5151+ @impl Atex.OAuth.SessionStore
5252+ @spec update(String.t(), Session.t()) :: :ok | {:error, atom()}
5353+ def update(key, session) do
5454+ insert(key, session)
5555+ end
5656+5757+ @doc """
5858+ Retrieve a session from the ETS table.
5959+6060+ Returns `{:ok, session}` if found, `{:error, :not_found}` otherwise.
6161+ """
6262+ @impl Atex.OAuth.SessionStore
6363+ @spec get(String.t()) :: {:ok, Session.t()} | {:error, atom()}
6464+ def get(key) do
6565+ case :ets.lookup(@table, key) do
6666+ [{_key, session}] -> {:ok, session}
6767+ [] -> {:error, :not_found}
6868+ end
6969+ end
7070+7171+ @doc """
7272+ Delete a session from the ETS table.
7373+7474+ Returns `:ok` if deleted, `:noop` if the session didn't exist.
7575+ """
7676+ @impl Atex.OAuth.SessionStore
7777+ @spec delete(String.t()) :: :ok | :error | :noop
7878+ def delete(key) do
7979+ case get(key) do
8080+ {:ok, _session} ->
8181+ :ets.delete(@table, key)
8282+ :ok
8383+8484+ {:error, :not_found} ->
8585+ :noop
8686+ end
8787+ end
8888+end
+119
lib/atex/oauth/session_store.ex
···11+defmodule Atex.OAuth.SessionStore do
22+ @moduledoc """
33+ Storage interface for OAuth sessions.
44+55+ Provides a behaviour for implementing session storage backends, and functions
66+ to operate the backend using `Atex.OAuth.Session`
77+88+ ## Configuration
99+1010+ The default implementation for the store is `Atex.OAuth.SessionStore.DETS`;
1111+ this can be changed to a custom implementation in your config.exs:
1212+1313+ config :atex, :session_store, Atex.OAuth.SessionStore.ETS
1414+1515+ DETS is the default implementation as it provides simple, on-disk storage for
1616+ sessions so they don't get discarded on an application restart, but a regular
1717+ ETS implementation is also provided out-of-the-box for testing or other
1818+ circumstances.
1919+2020+ For multi-node deployments, you can write your own implementation using a
2121+ custom backend, such as Redis, by implementing the behaviour callbacks.
2222+2323+ ## Usage
2424+2525+ Sessions are keyed by the user's DID (`sub` field).
2626+2727+ session = %Atex.OAuth.Session{
2828+ iss: "https://bsky.social",
2929+ aud: "https://puffball.us-east.host.bsky.network",
3030+ sub: "did:plc:abc123",
3131+ access_token: "...",
3232+ refresh_token: "...",
3333+ expires_at: ~N[2026-01-04 12:00:00],
3434+ dpop_key: dpop_key,
3535+ dpop_nonce: "server-nonce"
3636+ }
3737+3838+ # Insert a new session
3939+ :ok = Atex.OAuth.SessionStore.insert(session)
4040+4141+ # Retrieve a session
4242+ {:ok, session} = Atex.OAuth.SessionStore.get("did:plc:abc123")
4343+4444+ # Update an existing session (e.g., after token refresh)
4545+ updated_session = %{session | access_token: new_token}
4646+ :ok = Atex.OAuth.SessionStore.update(updated_session)
4747+4848+ # Delete a session
4949+ Atex.OAuth.SessionStore.delete(session)
5050+ """
5151+5252+ @store Application.compile_env(:atex, :session_store, Atex.OAuth.SessionStore.DETS)
5353+5454+ @doc """
5555+ Retrieve a session by DID.
5656+5757+ Returns `{:ok, session}` if found, `{:error, :not_found}` otherwise.
5858+ """
5959+ @callback get(key :: String.t()) :: {:ok, Atex.OAuth.Session.t()} | {:error, atom()}
6060+6161+ @doc """
6262+ Insert a new session.
6363+6464+ The key is the user's DID (`session.sub`). Returns `:ok` on success.
6565+ """
6666+ @callback insert(key :: String.t(), session :: Atex.OAuth.Session.t()) ::
6767+ :ok | {:error, atom()}
6868+6969+ @doc """
7070+ Update an existing session.
7171+7272+ Replaces the existing session data for the given key. Returns `:ok` on success.
7373+ """
7474+ @callback update(key :: String.t(), session :: Atex.OAuth.Session.t()) ::
7575+ :ok | {:error, atom()}
7676+7777+ @doc """
7878+ Delete a session.
7979+8080+ Returns `:ok` if deleted, `:noop` if the session didn't exist, :error if it failed.
8181+ """
8282+ @callback delete(key :: String.t()) :: :ok | :error | :noop
8383+8484+ @callback child_spec(any()) :: Supervisor.child_spec()
8585+8686+ defdelegate child_spec(opts), to: @store
8787+8888+ @doc """
8989+ Retrieve a session by DID.
9090+ """
9191+ @spec get(String.t()) :: {:ok, Atex.OAuth.Session.t()} | {:error, atom()}
9292+ def get(key) do
9393+ @store.get(key)
9494+ end
9595+9696+ @doc """
9797+ Insert a new session.
9898+ """
9999+ @spec insert(Atex.OAuth.Session.t()) :: :ok | {:error, atom()}
100100+ def insert(session) do
101101+ @store.insert(session.sub, session)
102102+ end
103103+104104+ @doc """
105105+ Update an existing session.
106106+ """
107107+ @spec update(Atex.OAuth.Session.t()) :: :ok | {:error, atom()}
108108+ def update(session) do
109109+ @store.update(session.sub, session)
110110+ end
111111+112112+ @doc """
113113+ Delete a session.
114114+ """
115115+ @callback delete(Atex.OAuth.Session.t()) :: :ok | :error | :noop
116116+ def delete(session) do
117117+ @store.delete(session.sub)
118118+ end
119119+end
+582
lib/atex/oauth.ex
···11+defmodule Atex.OAuth do
22+ @moduledoc """
33+ OAuth 2.0 implementation for AT Protocol authentication.
44+55+ This module provides utilities for implementing OAuth flows compliant with the
66+ AT Protocol specification. It includes support for:
77+88+ - Pushed Authorization Requests (PAR)
99+ - DPoP (Demonstration of Proof of Possession) tokens
1010+ - JWT client assertions
1111+ - PKCE (Proof Key for Code Exchange)
1212+ - Token refresh
1313+ - Handle to PDS resolution
1414+1515+ ## Configuration
1616+1717+ See `Atex.Config.OAuth` module for configuration documentation.
1818+1919+ ## Usage Example
2020+2121+ iex> pds = "https://bsky.social"
2222+ iex> login_hint = "example.com"
2323+ iex> {:ok, authz_server} = Atex.OAuth.get_authorization_server(pds)
2424+ iex> {:ok, authz_metadata} = Atex.OAuth.get_authorization_server_metadata(authz_server)
2525+ iex> state = Atex.OAuth.create_nonce()
2626+ iex> code_verifier = Atex.OAuth.create_nonce()
2727+ iex> {:ok, auth_url} = Atex.OAuth.create_authorization_url(
2828+ authz_metadata,
2929+ state,
3030+ code_verifier,
3131+ login_hint
3232+ )
3333+ """
3434+3535+ @type authorization_metadata() :: %{
3636+ issuer: String.t(),
3737+ par_endpoint: String.t(),
3838+ token_endpoint: String.t(),
3939+ authorization_endpoint: String.t()
4040+ }
4141+4242+ @type tokens() :: %{
4343+ access_token: String.t(),
4444+ refresh_token: String.t(),
4545+ did: String.t(),
4646+ expires_at: NaiveDateTime.t()
4747+ }
4848+4949+ alias Atex.Config.OAuth, as: Config
5050+5151+ @doc """
5252+ Get a map cnotaining the client metadata information needed for an
5353+ authorization server to validate this client.
5454+ """
5555+ @spec create_client_metadata() :: map()
5656+ def create_client_metadata() do
5757+ key = Config.get_key()
5858+ {_, jwk} = key |> JOSE.JWK.to_public_map()
5959+ jwk = Map.merge(jwk, %{use: "sig", kid: key.fields["kid"]})
6060+6161+ %{
6262+ client_id: Config.client_id(),
6363+ redirect_uris: [Config.redirect_uri() | Config.extra_redirect_uris()],
6464+ application_type: "web",
6565+ grant_types: ["authorization_code", "refresh_token"],
6666+ scope: Config.scopes(),
6767+ response_type: ["code"],
6868+ token_endpoint_auth_method: "private_key_jwt",
6969+ token_endpoint_auth_signing_alg: "ES256",
7070+ dpop_bound_access_tokens: true,
7171+ jwks: %{keys: [jwk]}
7272+ }
7373+ end
7474+7575+ @doc """
7676+ Retrieves the configured JWT private key for signing client assertions.
7777+7878+ Loads the private key from configuration, decodes the base64-encoded DER data,
7979+ and creates a JOSE JWK structure with the key ID field set.
8080+8181+ ## Returns
8282+8383+ A `JOSE.JWK` struct containing the private key and key identifier.
8484+8585+ ## Raises
8686+8787+ * `Application.Env.Error` if the private_key or key_id configuration is missing
8888+8989+ ## Examples
9090+9191+ key = OAuth.get_key()
9292+ key = OAuth.get_key()
9393+ """
9494+ @spec get_key() :: JOSE.JWK.t()
9595+ def get_key(), do: Config.get_key()
9696+9797+ @doc false
9898+ @spec random_b64(integer()) :: String.t()
9999+ def random_b64(length) do
100100+ :crypto.strong_rand_bytes(length)
101101+ |> Base.url_encode64(padding: false)
102102+ end
103103+104104+ @doc false
105105+ @spec create_nonce() :: String.t()
106106+ def create_nonce(), do: random_b64(32)
107107+108108+ @doc """
109109+ Create an OAuth authorization URL for a PDS.
110110+111111+ Submits a PAR request to the authorization server and constructs the
112112+ authorization URL with the returned request URI. Supports PKCE, DPoP, and
113113+ client assertions as required by the AT Protocol.
114114+115115+ ## Parameters
116116+117117+ - `authz_metadata` - Authorization server metadata containing endpoints, fetched from `get_authorization_server_metadata/1`
118118+ - `state` - Random token for session validation
119119+ - `code_verifier` - PKCE code verifier
120120+ - `login_hint` - User identifier (handle or DID) for pre-filled login
121121+122122+ ## Returns
123123+124124+ - `{:ok, authorization_url}` - Successfully created authorization URL
125125+ - `{:ok, :invalid_par_response}` - Server respondend incorrectly to the request
126126+ - `{:error, reason}` - Error creating authorization URL
127127+ """
128128+ @spec create_authorization_url(
129129+ authorization_metadata(),
130130+ String.t(),
131131+ String.t(),
132132+ String.t()
133133+ ) :: {:ok, String.t()} | {:error, any()}
134134+ def create_authorization_url(
135135+ authz_metadata,
136136+ state,
137137+ code_verifier,
138138+ login_hint
139139+ ) do
140140+ code_challenge = :crypto.hash(:sha256, code_verifier) |> Base.url_encode64(padding: false)
141141+ key = get_key()
142142+143143+ client_assertion =
144144+ create_client_assertion(key, Config.client_id(), authz_metadata.issuer)
145145+146146+ body =
147147+ %{
148148+ response_type: "code",
149149+ client_id: Config.client_id(),
150150+ redirect_uri: Config.redirect_uri(),
151151+ state: state,
152152+ code_challenge_method: "S256",
153153+ code_challenge: code_challenge,
154154+ scope: Config.scopes(),
155155+ client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
156156+ client_assertion: client_assertion,
157157+ login_hint: login_hint
158158+ }
159159+160160+ case Req.post(authz_metadata.par_endpoint, form: body) do
161161+ {:ok, %{body: %{"request_uri" => request_uri}}} ->
162162+ query =
163163+ %{client_id: Config.client_id(), request_uri: request_uri}
164164+ |> URI.encode_query()
165165+166166+ {:ok, "#{authz_metadata.authorization_endpoint}?#{query}"}
167167+168168+ {:ok, _} ->
169169+ {:error, :invalid_par_response}
170170+171171+ err ->
172172+ err
173173+ end
174174+ end
175175+176176+ @doc """
177177+ Exchange an OAuth authorization code for a set of access and refresh tokens.
178178+179179+ Validates the authorization code by submitting it to the token endpoint along with
180180+ the PKCE code verifier and client assertion. Returns access tokens for making authenticated
181181+ requests to the relevant user's PDS.
182182+183183+ ## Parameters
184184+185185+ - `authz_metadata` - Authorization server metadata containing token endpoint
186186+ - `dpop_key` - JWK for DPoP token generation
187187+ - `code` - Authorization code from OAuth callback
188188+ - `code_verifier` - PKCE code verifier from authorization flow
189189+190190+ ## Returns
191191+192192+ - `{:ok, tokens, nonce}` - Successfully obtained tokens with returned DPoP nonce
193193+ - `{:error, reason}` - Error exchanging code for tokens
194194+ """
195195+ @spec validate_authorization_code(
196196+ authorization_metadata(),
197197+ JOSE.JWK.t(),
198198+ String.t(),
199199+ String.t()
200200+ ) :: {:ok, tokens(), String.t()} | {:error, any()}
201201+ def validate_authorization_code(
202202+ authz_metadata,
203203+ dpop_key,
204204+ code,
205205+ code_verifier
206206+ ) do
207207+ key = get_key()
208208+209209+ client_assertion =
210210+ create_client_assertion(key, Config.client_id(), authz_metadata.issuer)
211211+212212+ body =
213213+ %{
214214+ grant_type: "authorization_code",
215215+ client_id: Config.client_id(),
216216+ redirect_uri: Config.redirect_uri(),
217217+ code: code,
218218+ code_verifier: code_verifier,
219219+ client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
220220+ client_assertion: client_assertion
221221+ }
222222+223223+ Req.new(method: :post, url: authz_metadata.token_endpoint, form: body)
224224+ |> send_oauth_dpop_request(dpop_key)
225225+ |> case do
226226+ {:ok,
227227+ %{
228228+ "access_token" => access_token,
229229+ "refresh_token" => refresh_token,
230230+ "expires_in" => expires_in,
231231+ "sub" => did
232232+ }, nonce} ->
233233+ expires_at = NaiveDateTime.utc_now() |> NaiveDateTime.add(expires_in, :second)
234234+235235+ {:ok,
236236+ %{
237237+ access_token: access_token,
238238+ refresh_token: refresh_token,
239239+ did: did,
240240+ expires_at: expires_at
241241+ }, nonce}
242242+243243+ err ->
244244+ err
245245+ end
246246+ end
247247+248248+ def refresh_token(refresh_token, dpop_key, issuer, token_endpoint) do
249249+ key = get_key()
250250+251251+ client_assertion =
252252+ create_client_assertion(key, Config.client_id(), issuer)
253253+254254+ body = %{
255255+ grant_type: "refresh_token",
256256+ refresh_token: refresh_token,
257257+ client_id: Config.client_id(),
258258+ client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
259259+ client_assertion: client_assertion
260260+ }
261261+262262+ Req.new(method: :post, url: token_endpoint, form: body)
263263+ |> send_oauth_dpop_request(dpop_key)
264264+ |> case do
265265+ {:ok,
266266+ %{
267267+ "access_token" => access_token,
268268+ "refresh_token" => refresh_token,
269269+ "expires_in" => expires_in,
270270+ "sub" => did
271271+ }, nonce} ->
272272+ expires_at = NaiveDateTime.utc_now() |> NaiveDateTime.add(expires_in, :second)
273273+274274+ {:ok,
275275+ %{
276276+ access_token: access_token,
277277+ refresh_token: refresh_token,
278278+ did: did,
279279+ expires_at: expires_at
280280+ }, nonce}
281281+282282+ err ->
283283+ err
284284+ end
285285+ end
286286+287287+ @doc """
288288+ Fetch the authorization server for a given Personal Data Server (PDS).
289289+290290+ Makes a request to the PDS's `.well-known/oauth-protected-resource` endpoint
291291+ to discover the associated authorization server that should be used for the
292292+ OAuth flow. Results are cached for 1 hour to reduce load on third-party PDSs.
293293+294294+ ## Parameters
295295+296296+ - `pds_host` - Base URL of the PDS (e.g., "https://bsky.social")
297297+ - `fresh` - If `true`, bypasses the cache and fetches fresh data (default: `false`)
298298+299299+ ## Returns
300300+301301+ - `{:ok, authorization_server}` - Successfully discovered authorization
302302+ server URL
303303+ - `{:error, :invalid_metadata}` - Server returned invalid metadata
304304+ - `{:error, reason}` - Error discovering authorization server
305305+ """
306306+ @spec get_authorization_server(String.t(), boolean()) :: {:ok, String.t()} | {:error, any()}
307307+ def get_authorization_server(pds_host, fresh \\ false) do
308308+ if fresh do
309309+ fetch_authorization_server(pds_host)
310310+ else
311311+ case Atex.OAuth.Cache.get_authorization_server(pds_host) do
312312+ {:ok, authz_server} ->
313313+ {:ok, authz_server}
314314+315315+ {:error, :not_found} ->
316316+ fetch_authorization_server(pds_host)
317317+ end
318318+ end
319319+ end
320320+321321+ defp fetch_authorization_server(pds_host) do
322322+ result =
323323+ "#{pds_host}/.well-known/oauth-protected-resource"
324324+ |> Req.get()
325325+ |> case do
326326+ # TODO: what to do when multiple authorization servers?
327327+ {:ok, %{body: %{"authorization_servers" => [authz_server | _]}}} -> {:ok, authz_server}
328328+ {:ok, _} -> {:error, :invalid_metadata}
329329+ err -> err
330330+ end
331331+332332+ case result do
333333+ {:ok, authz_server} ->
334334+ Atex.OAuth.Cache.set_authorization_server(pds_host, authz_server)
335335+ {:ok, authz_server}
336336+337337+ error ->
338338+ error
339339+ end
340340+ end
341341+342342+ @doc """
343343+ Fetch the metadata for an OAuth authorization server.
344344+345345+ Retrieves the metadata from the authorization server's
346346+ `.well-known/oauth-authorization-server` endpoint, providing endpoint URLs
347347+ required for the OAuth flow. Results are cached for 1 hour to reduce load on
348348+ third-party PDSs.
349349+350350+ ## Parameters
351351+352352+ - `issuer` - Authorization server issuer URL
353353+ - `fresh` - If `true`, bypasses the cache and fetches fresh data (default: `false`)
354354+355355+ ## Returns
356356+357357+ - `{:ok, metadata}` - Successfully retrieved authorization server metadata
358358+ - `{:error, :invalid_metadata}` - Server returned invalid metadata
359359+ - `{:error, :invalid_issuer}` - Issuer mismatch in metadata
360360+ - `{:error, any()}` - Other error fetching metadata
361361+ """
362362+ @spec get_authorization_server_metadata(String.t(), boolean()) ::
363363+ {:ok, authorization_metadata()} | {:error, any()}
364364+ def get_authorization_server_metadata(issuer, fresh \\ false) do
365365+ if fresh do
366366+ fetch_authorization_server_metadata(issuer)
367367+ else
368368+ case Atex.OAuth.Cache.get_authorization_server_metadata(issuer) do
369369+ {:ok, metadata} ->
370370+ {:ok, metadata}
371371+372372+ {:error, :not_found} ->
373373+ fetch_authorization_server_metadata(issuer)
374374+ end
375375+ end
376376+ end
377377+378378+ defp fetch_authorization_server_metadata(issuer) do
379379+ result =
380380+ "#{issuer}/.well-known/oauth-authorization-server"
381381+ |> Req.get()
382382+ |> case do
383383+ {:ok,
384384+ %{
385385+ body: %{
386386+ "issuer" => metadata_issuer,
387387+ "pushed_authorization_request_endpoint" => par_endpoint,
388388+ "token_endpoint" => token_endpoint,
389389+ "authorization_endpoint" => authorization_endpoint
390390+ }
391391+ }} ->
392392+ if issuer != metadata_issuer do
393393+ {:error, :invaild_issuer}
394394+ else
395395+ {:ok,
396396+ %{
397397+ issuer: metadata_issuer,
398398+ par_endpoint: par_endpoint,
399399+ token_endpoint: token_endpoint,
400400+ authorization_endpoint: authorization_endpoint
401401+ }}
402402+ end
403403+404404+ {:ok, _} ->
405405+ {:error, :invalid_metadata}
406406+407407+ err ->
408408+ err
409409+ end
410410+411411+ case result do
412412+ {:ok, metadata} ->
413413+ Atex.OAuth.Cache.set_authorization_server_metadata(issuer, metadata)
414414+ {:ok, metadata}
415415+416416+ error ->
417417+ error
418418+ end
419419+ end
420420+421421+ @spec send_oauth_dpop_request(Req.Request.t(), JOSE.JWK.t(), String.t() | nil) ::
422422+ {:ok, map(), String.t()} | {:error, any(), String.t()}
423423+ def send_oauth_dpop_request(request, dpop_key, nonce \\ nil) do
424424+ dpop_token = create_dpop_token(dpop_key, request, nonce)
425425+426426+ request
427427+ |> Req.Request.put_header("dpop", dpop_token)
428428+ |> Req.request()
429429+ |> case do
430430+ {:ok, resp} ->
431431+ dpop_nonce =
432432+ case resp.headers["dpop-nonce"] do
433433+ [new_nonce | _] -> new_nonce
434434+ _ -> nonce
435435+ end
436436+437437+ cond do
438438+ resp.status == 200 ->
439439+ {:ok, resp.body, dpop_nonce}
440440+441441+ resp.body["error"] === "use_dpop_nonce" ->
442442+ dpop_token = create_dpop_token(dpop_key, request, dpop_nonce)
443443+444444+ request
445445+ |> Req.Request.put_header("dpop", dpop_token)
446446+ |> Req.request()
447447+ |> case do
448448+ {:ok, %{status: 200, body: body}} ->
449449+ {:ok, body, dpop_nonce}
450450+451451+ {:ok, %{body: %{"error" => error, "error_description" => error_description}}} ->
452452+ {:error, {:oauth_error, error, error_description}, dpop_nonce}
453453+454454+ {:ok, _} ->
455455+ {:error, :unexpected_response, dpop_nonce}
456456+457457+ {:error, err} ->
458458+ {:error, err, dpop_nonce}
459459+ end
460460+461461+ true ->
462462+ {:error, {:oauth_error, resp.body["error"], resp.body["error_description"]},
463463+ dpop_nonce}
464464+ end
465465+466466+ {:error, err} ->
467467+ {:error, err, nonce}
468468+ end
469469+ end
470470+471471+ @spec request_protected_dpop_resource(
472472+ Req.Request.t(),
473473+ String.t(),
474474+ String.t(),
475475+ JOSE.JWK.t(),
476476+ String.t() | nil
477477+ ) :: {:ok, Req.Response.t(), String.t() | nil} | {:error, any()}
478478+ def request_protected_dpop_resource(request, issuer, access_token, dpop_key, nonce \\ nil) do
479479+ access_token_hash = :crypto.hash(:sha256, access_token) |> Base.url_encode64(padding: false)
480480+ # access_token_hash = Base.url_encode64(access_token, padding: false)
481481+482482+ dpop_token =
483483+ create_dpop_token(dpop_key, request, nonce, %{iss: issuer, ath: access_token_hash})
484484+485485+ request
486486+ |> Req.Request.put_header("dpop", dpop_token)
487487+ |> Req.request()
488488+ |> case do
489489+ {:ok, resp} ->
490490+ dpop_nonce =
491491+ case resp.headers["dpop-nonce"] do
492492+ [new_nonce | _] -> new_nonce
493493+ _ -> nonce
494494+ end
495495+496496+ www_authenticate = Req.Response.get_header(resp, "www-authenticate")
497497+498498+ www_dpop_problem =
499499+ www_authenticate != [] && String.starts_with?(Enum.at(www_authenticate, 0), "DPoP")
500500+501501+ if resp.status != 401 || !www_dpop_problem do
502502+ {:ok, resp, dpop_nonce}
503503+ else
504504+ dpop_token =
505505+ create_dpop_token(dpop_key, request, dpop_nonce, %{
506506+ iss: issuer,
507507+ ath: access_token_hash
508508+ })
509509+510510+ request
511511+ |> Req.Request.put_header("dpop", dpop_token)
512512+ |> Req.request()
513513+ |> case do
514514+ {:ok, resp} ->
515515+ dpop_nonce =
516516+ case resp.headers["dpop-nonce"] do
517517+ [new_nonce | _] -> new_nonce
518518+ _ -> dpop_nonce
519519+ end
520520+521521+ {:ok, resp, dpop_nonce}
522522+523523+ err ->
524524+ err
525525+ end
526526+ end
527527+528528+ err ->
529529+ err
530530+ end
531531+ end
532532+533533+ @spec create_client_assertion(JOSE.JWK.t(), String.t(), String.t()) :: String.t()
534534+ def create_client_assertion(jwk, client_id, issuer) do
535535+ iat = System.os_time(:second)
536536+ jti = random_b64(20)
537537+ jws = %{"alg" => "ES256", "kid" => jwk.fields["kid"]}
538538+539539+ jwt = %{
540540+ iss: client_id,
541541+ sub: client_id,
542542+ aud: issuer,
543543+ jti: jti,
544544+ iat: iat,
545545+ exp: iat + 60
546546+ }
547547+548548+ JOSE.JWT.sign(jwk, jws, jwt)
549549+ |> JOSE.JWS.compact()
550550+ |> elem(1)
551551+ end
552552+553553+ @spec create_dpop_token(JOSE.JWK.t(), Req.Request.t(), any(), map()) :: String.t()
554554+ def create_dpop_token(jwk, request, nonce \\ nil, attrs \\ %{}) do
555555+ iat = System.os_time(:second)
556556+ jti = random_b64(20)
557557+ {_, public_jwk} = JOSE.JWK.to_public_map(jwk)
558558+ jws = %{"alg" => "ES256", "typ" => "dpop+jwt", "jwk" => public_jwk}
559559+ [request_url | _] = request.url |> to_string() |> String.split("?")
560560+561561+ jwt =
562562+ Map.merge(attrs, %{
563563+ jti: jti,
564564+ htm: atom_to_upcase_string(request.method),
565565+ htu: request_url,
566566+ iat: iat
567567+ })
568568+ |> then(fn m ->
569569+ if nonce, do: Map.put(m, :nonce, nonce), else: m
570570+ end)
571571+572572+ JOSE.JWT.sign(jwk, jws, jwt)
573573+ |> JOSE.JWS.compact()
574574+ |> elem(1)
575575+ end
576576+577577+ @doc false
578578+ @spec atom_to_upcase_string(atom()) :: String.t()
579579+ def atom_to_upcase_string(atom) do
580580+ atom |> to_string() |> String.upcase()
581581+ end
582582+end
+40
lib/atex/peri.ex
···11+defmodule Atex.Peri do
22+ @moduledoc """
33+ Custom validators for Peri, for use within atex.
44+ """
55+66+ def uri, do: {:custom, &validate_uri/1}
77+ def did, do: {:string, {:regex, ~r/^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$/}}
88+99+ defp validate_uri(uri) when is_binary(uri) do
1010+ case URI.new(uri) do
1111+ {:ok, _} -> :ok
1212+ {:error, _} -> {:error, "must be a valid URI", [uri: uri]}
1313+ end
1414+ end
1515+1616+ defp validate_uri(uri), do: {:error, "must be a valid URI", [uri: uri]}
1717+1818+ def validate_map(value, schema, extra_keys_schema) when is_map(value) and is_map(schema) do
1919+ extra_keys =
2020+ Enum.reduce(Map.keys(schema), MapSet.new(Map.keys(value)), fn key, acc ->
2121+ acc |> MapSet.delete(key) |> MapSet.delete(to_string(key))
2222+ end)
2323+2424+ extra_data =
2525+ value
2626+ |> Enum.filter(fn {key, _} -> MapSet.member?(extra_keys, key) end)
2727+ |> Map.new()
2828+2929+ with {:ok, schema_data} <- Peri.validate(schema, value),
3030+ {:ok, extra_data} <- Peri.validate(extra_keys_schema, extra_data) do
3131+ {:ok, Map.merge(schema_data, extra_data)}
3232+ else
3333+ {:error, %Peri.Error{} = err} -> {:error, [err]}
3434+ e -> e
3535+ end
3636+ end
3737+3838+ def validate_map(value, _schema, _extra_keys_schema),
3939+ do: {:error, "must be a map", [value: value]}
4040+end
+189
lib/atex/tid.ex
···11+defmodule Atex.TID do
22+ @moduledoc """
33+ Struct and helper functions for dealing with AT Protocol TIDs (Timestamp
44+ Identifiers), a 13-character string representation of a 64-bit number
55+ comprised of a Unix timestamp (in microsecond precision) and a random "clock
66+ identifier" to help avoid collisions.
77+88+ ATProto spec: https://atproto.com/specs/tid
99+1010+ TID strings are always 13 characters long. All bits in the 64-bit number are
1111+ encoded, essentially meaning that the string is padded with "2" if necessary,
1212+ (the 0th character in the base32-sortable alphabet).
1313+ """
1414+ import Bitwise
1515+ alias Atex.Base32Sortable
1616+ use TypedStruct
1717+1818+ @re ~r/^[234567abcdefghij][234567abcdefghijklmnopqrstuvwxyz]{12}$/
1919+2020+ @typedoc """
2121+ A Unix timestamp representing when the TID was created.
2222+ """
2323+ @type timestamp() :: integer()
2424+2525+ @typedoc """
2626+ An integer to be used for the lower 10 bits of the TID.
2727+ """
2828+ @type clock_id() :: 0..1023
2929+3030+ typedstruct enforce: true do
3131+ field :timestamp, timestamp()
3232+ field :clock_id, clock_id()
3333+ end
3434+3535+ @doc """
3636+ Returns a TID for the current moment in time, along with a random clock ID.
3737+ """
3838+ @spec now() :: t()
3939+ def now,
4040+ do: %__MODULE__{
4141+ timestamp: DateTime.utc_now(:microsecond) |> DateTime.to_unix(:microsecond),
4242+ clock_id: gen_clock_id()
4343+ }
4444+4545+ @doc """
4646+ Create a new TID from a `DateTime` or an integer representing a Unix timestamp in microseconds.
4747+4848+ If `clock_id` isn't provided, a random one will be generated.
4949+ """
5050+ @spec new(DateTime.t() | integer(), integer() | nil) :: t()
5151+ def new(source, clock_id \\ nil)
5252+5353+ def new(%DateTime{} = datetime, clock_id),
5454+ do: %__MODULE__{
5555+ timestamp: DateTime.to_unix(datetime, :microsecond),
5656+ clock_id: clock_id || gen_clock_id()
5757+ }
5858+5959+ def new(unix, clock_id) when is_integer(unix),
6060+ do: %__MODULE__{timestamp: unix, clock_id: clock_id || gen_clock_id()}
6161+6262+ @doc """
6363+ Convert a TID struct to an instance of `DateTime`.
6464+ """
6565+ def to_datetime(%__MODULE__{} = tid), do: DateTime.from_unix(tid.timestamp, :microsecond)
6666+6767+ @doc """
6868+ Generate a random integer to be used as a `clock_id`.
6969+ """
7070+ @spec gen_clock_id() :: clock_id()
7171+ def gen_clock_id, do: :rand.uniform(1024) - 1
7272+7373+ @doc """
7474+ Decode a TID string into an `Atex.TID` struct, returning an error if it's invalid.
7575+7676+ ## Examples
7777+7878+ Syntactically valid TIDs:
7979+8080+ iex> Atex.TID.decode("3jzfcijpj2z2a")
8181+ {:ok, %Atex.TID{clock_id: 6, timestamp: 1688137381887007}}
8282+8383+ iex> Atex.TID.decode("7777777777777")
8484+ {:ok, %Atex.TID{clock_id: 165, timestamp: 5811096293381285}}
8585+8686+ iex> Atex.TID.decode("3zzzzzzzzzzzz")
8787+ {:ok, %Atex.TID{clock_id: 1023, timestamp: 2251799813685247}}
8888+8989+ iex> Atex.TID.decode("2222222222222")
9090+ {:ok, %Atex.TID{clock_id: 0, timestamp: 0}}
9191+9292+ Invalid TIDs:
9393+9494+ # not base32
9595+ iex> Atex.TID.decode("3jzfcijpj2z21")
9696+ :error
9797+ iex> Atex.TID.decode("0000000000000")
9898+ :error
9999+100100+ # case-sensitive
101101+ iex> Atex.TID.decode("3JZFCIJPJ2Z2A")
102102+ :error
103103+104104+ # too long/short
105105+ iex> Atex.TID.decode("3jzfcijpj2z2aa")
106106+ :error
107107+ iex> Atex.TID.decode("3jzfcijpj2z2")
108108+ :error
109109+ iex> Atex.TID.decode("222")
110110+ :error
111111+112112+ # legacy dash syntax *not* supported (TTTT-TTT-TTTT-CC)
113113+ iex> Atex.TID.decode("3jzf-cij-pj2z-2a")
114114+ :error
115115+116116+ # high bit can't be set
117117+ iex> Atex.TID.decode("zzzzzzzzzzzzz")
118118+ :error
119119+ iex> Atex.TID.decode("kjzfcijpj2z2a")
120120+ :error
121121+122122+ """
123123+ @spec decode(String.t()) :: {:ok, t()} | :error
124124+ def decode(<<timestamp::binary-size(11), clock_id::binary-size(2)>> = tid) do
125125+ if match?(tid) do
126126+ timestamp = Base32Sortable.decode(timestamp)
127127+ clock_id = Base32Sortable.decode(clock_id)
128128+129129+ {:ok,
130130+ %__MODULE__{
131131+ timestamp: timestamp,
132132+ clock_id: clock_id
133133+ }}
134134+ else
135135+ :error
136136+ end
137137+ end
138138+139139+ def decode(_tid), do: :error
140140+141141+ @doc """
142142+ Encode a TID struct into a string.
143143+144144+ ## Examples
145145+146146+ iex> Atex.TID.encode(%Atex.TID{clock_id: 6, timestamp: 1688137381887007})
147147+ "3jzfcijpj2z2a"
148148+149149+ iex> Atex.TID.encode(%Atex.TID{clock_id: 165, timestamp: 5811096293381285})
150150+ "7777777777777"
151151+152152+ iex> Atex.TID.encode(%Atex.TID{clock_id: 1023, timestamp: 2251799813685247})
153153+ "3zzzzzzzzzzzz"
154154+155155+ iex> Atex.TID.encode(%Atex.TID{clock_id: 0, timestamp: 0})
156156+ "2222222222222"
157157+158158+ """
159159+ @spec encode(t()) :: String.t()
160160+ def encode(%__MODULE__{} = tid) do
161161+ timestamp = tid.timestamp |> Base32Sortable.encode() |> String.pad_leading(11, "2")
162162+ clock_id = (tid.clock_id &&& 1023) |> Base32Sortable.encode() |> String.pad_leading(2, "2")
163163+ timestamp <> clock_id
164164+ end
165165+166166+ @doc """
167167+ Check if a given string matches the format for a TID.
168168+169169+ ## Examples
170170+171171+ iex> Atex.TID.match?("3jzfcijpj2z2a")
172172+ true
173173+174174+ iex> Atex.TID.match?("2222222222222")
175175+ true
176176+177177+ iex> Atex.TID.match?("banana")
178178+ false
179179+180180+ iex> Atex.TID.match?("kjzfcijpj2z2a")
181181+ false
182182+ """
183183+ @spec match?(String.t()) :: boolean()
184184+ def match?(value), do: Regex.match?(@re, value)
185185+end
186186+187187+defimpl String.Chars, for: Atex.TID do
188188+ def to_string(tid), do: Atex.TID.encode(tid)
189189+end
+31
lib/atex/xrpc/client.ex
···11+defmodule Atex.XRPC.Client do
22+ @moduledoc """
33+ Behaviour that defines the interface for XRPC clients.
44+55+ This behaviour allows different types of clients (login-based, OAuth-based, etc.)
66+ to implement authentication and request handling while maintaining a consistent interface.
77+88+ Implementations must handle token refresh internally when requests fail due to
99+ expired tokens, and return both the result and potentially updated client state.
1010+ """
1111+1212+ @type client :: struct()
1313+ @type request_opts :: keyword()
1414+ @type request_result :: {:ok, Req.Response.t(), client()} | {:error, any(), client()}
1515+1616+ @doc """
1717+ Perform an authenticated HTTP GET request on an XRPC resource.
1818+1919+ Implementations should handle token refresh if the request fails due to
2020+ expired authentication, returning both the response and the (potentially updated) client.
2121+ """
2222+ @callback get(client(), String.t(), request_opts()) :: request_result()
2323+2424+ @doc """
2525+ Perform an authenticated HTTP POST request on an XRPC resource.
2626+2727+ Implementations should handle token refresh if the request fails due to
2828+ expired authentication, returning both the response and the (potentially updated) client.
2929+ """
3030+ @callback post(client(), String.t(), request_opts()) :: request_result()
3131+end
+148
lib/atex/xrpc/login_client.ex
···11+defmodule Atex.XRPC.LoginClient do
22+ alias Atex.XRPC
33+ use TypedStruct
44+55+ @behaviour Atex.XRPC.Client
66+77+ typedstruct do
88+ field :endpoint, String.t(), enforce: true
99+ field :access_token, String.t() | nil
1010+ field :refresh_token, String.t() | nil
1111+ end
1212+1313+ @doc """
1414+ Create a new `Atex.XRPC.LoginClient` from an endpoint, and optionally an
1515+ existing access/refresh token.
1616+1717+ Endpoint should be the base URL of a PDS, or an AppView in the case of
1818+ unauthenticated requests (like Bluesky's public API), e.g.
1919+ `https://bsky.social`.
2020+ """
2121+ @spec new(String.t(), String.t() | nil, String.t() | nil) :: t()
2222+ def new(endpoint, access_token \\ nil, refresh_token \\ nil) do
2323+ %__MODULE__{endpoint: endpoint, access_token: access_token, refresh_token: refresh_token}
2424+ end
2525+2626+ @doc """
2727+ Create a new `Atex.XRPC.LoginClient` by logging in with an `identifier` and
2828+ `password` to fetch an initial pair of access & refresh tokens.
2929+3030+ Also supports providing a MFA token in the situation that is required.
3131+3232+ Uses `com.atproto.server.createSession` under the hood, so `identifier` can be
3333+ either a handle or a DID.
3434+3535+ ## Examples
3636+3737+ iex> Atex.XRPC.LoginClient.login("https://bsky.social", "example.com", "password123")
3838+ {:ok, %Atex.XRPC.LoginClient{...}}
3939+ """
4040+ @spec login(String.t(), String.t(), String.t()) :: {:ok, t()} | {:error, any()}
4141+ @spec login(String.t(), String.t(), String.t(), String.t() | nil) ::
4242+ {:ok, t()} | {:error, any()}
4343+ def login(endpoint, identifier, password, auth_factor_token \\ nil) do
4444+ json =
4545+ %{identifier: identifier, password: password}
4646+ |> then(
4747+ &if auth_factor_token do
4848+ Map.merge(&1, %{authFactorToken: auth_factor_token})
4949+ else
5050+ &1
5151+ end
5252+ )
5353+5454+ response = XRPC.unauthed_post(endpoint, "com.atproto.server.createSession", json: json)
5555+5656+ case response do
5757+ {:ok, %{body: %{"accessJwt" => access_token, "refreshJwt" => refresh_token}}} ->
5858+ {:ok, new(endpoint, access_token, refresh_token)}
5959+6060+ err ->
6161+ err
6262+ end
6363+ end
6464+6565+ @doc """
6666+ Request a new `refresh_token` for the given client.
6767+ """
6868+ @spec refresh(t()) :: {:ok, t()} | {:error, any()}
6969+ def refresh(%__MODULE__{endpoint: endpoint, refresh_token: refresh_token} = client) do
7070+ request =
7171+ Req.new(method: :post, url: XRPC.url(endpoint, "com.atproto.server.refreshSession"))
7272+ |> put_auth(refresh_token)
7373+7474+ case Req.request(request) do
7575+ {:ok, %{body: %{"accessJwt" => access_token, "refreshJwt" => refresh_token}}} ->
7676+ {:ok, %{client | access_token: access_token, refresh_token: refresh_token}}
7777+7878+ {:ok, response} ->
7979+ {:error, response}
8080+8181+ err ->
8282+ err
8383+ end
8484+ end
8585+8686+ @impl true
8787+ def get(%__MODULE__{} = client, resource, opts \\ []) do
8888+ request(client, opts ++ [method: :get, url: XRPC.url(client.endpoint, resource)])
8989+ end
9090+9191+ @impl true
9292+ def post(%__MODULE__{} = client, resource, opts \\ []) do
9393+ request(client, opts ++ [method: :post, url: XRPC.url(client.endpoint, resource)])
9494+ end
9595+9696+ @spec request(t(), keyword()) :: {:ok, Req.Response.t(), t()} | {:error, any()}
9797+ defp request(client, opts) do
9898+ with {:ok, client} <- validate_client(client) do
9999+ request = opts |> Req.new() |> put_auth(client.access_token)
100100+101101+ case Req.request(request) do
102102+ {:ok, %{status: 200} = response} ->
103103+ {:ok, response, client}
104104+105105+ {:ok, response} ->
106106+ handle_failure(client, response, request)
107107+108108+ err ->
109109+ err
110110+ end
111111+ end
112112+ end
113113+114114+ @spec handle_failure(t(), Req.Response.t(), Req.Request.t()) ::
115115+ {:ok, Req.Response.t(), t()} | {:error, any()}
116116+ defp handle_failure(client, response, request) do
117117+ IO.inspect(response, label: "got failure")
118118+119119+ if auth_error?(response.body) and client.refresh_token do
120120+ case refresh(client) do
121121+ {:ok, client} ->
122122+ case Req.request(put_auth(request, client.access_token)) do
123123+ {:ok, %{status: 200} = response} -> {:ok, response, client}
124124+ {:ok, response} -> {:error, response}
125125+ err -> err
126126+ end
127127+128128+ err ->
129129+ err
130130+ end
131131+ else
132132+ {:error, response}
133133+ end
134134+ end
135135+136136+ @spec validate_client(t()) :: {:ok, t()} | {:error, any()}
137137+ defp validate_client(%__MODULE__{access_token: nil}), do: {:error, :no_token}
138138+ defp validate_client(%__MODULE__{} = client), do: {:ok, client}
139139+140140+ @spec auth_error?(body :: Req.Response.t()) :: boolean()
141141+ defp auth_error?(%{status: status}) when status in [401, 403], do: true
142142+ defp auth_error?(%{body: %{"error" => "InvalidToken"}}), do: true
143143+ defp auth_error?(_response), do: false
144144+145145+ @spec put_auth(Req.Request.t(), String.t()) :: Req.Request.t()
146146+ defp put_auth(request, token),
147147+ do: Req.Request.put_header(request, "authorization", "Bearer #{token}")
148148+end
+292
lib/atex/xrpc/oauth_client.ex
···11+defmodule Atex.XRPC.OAuthClient do
22+ @moduledoc """
33+ OAuth client for making authenticated XRPC requests to AT Protocol servers.
44+55+ The client contains a user's DID and talks to `Atex.OAuth.SessionStore` to
66+ retrieve sessions internally to make requests. As a result, it will only work
77+ for users that have gone through an OAuth flow; see `Atex.OAuth.Plug` for an
88+ existing method of doing that.
99+1010+ The entire OAuth session lifecycle is handled transparently, with the access
1111+ token being refreshed automatically as required.
1212+1313+ ## Usage
1414+1515+ # Create from an existing OAuth session
1616+ {:ok, client} = Atex.XRPC.OAuthClient.new("did:plc:abc123")
1717+1818+ # Or extract from a Plug.Conn after OAuth flow
1919+ {:ok, client} = Atex.XRPC.OAuthClient.from_conn(conn)
2020+2121+ # Make XRPC requests
2222+ {:ok, response, client} = Atex.XRPC.get(client, "com.atproto.repo.listRecords")
2323+ """
2424+2525+ alias Atex.OAuth
2626+ use TypedStruct
2727+2828+ @behaviour Atex.XRPC.Client
2929+3030+ typedstruct enforce: true do
3131+ field :did, String.t()
3232+ end
3333+3434+ @doc """
3535+ Create a new OAuthClient from a DID.
3636+3737+ Validates that an OAuth session exists for the given DID in the session store
3838+ before returning the client struct.
3939+4040+ ## Examples
4141+4242+ iex> Atex.XRPC.OAuthClient.new("did:plc:abc123")
4343+ {:ok, %Atex.XRPC.OAuthClient{did: "did:plc:abc123"}}
4444+4545+ iex> Atex.XRPC.OAuthClient.new("did:plc:nosession")
4646+ {:error, :not_found}
4747+4848+ """
4949+ @spec new(String.t()) :: {:ok, t()} | {:error, atom()}
5050+ def new(did) do
5151+ # Make sure session exists before returning a struct
5252+ case Atex.OAuth.SessionStore.get(did) do
5353+ {:ok, _session} ->
5454+ {:ok, %__MODULE__{did: did}}
5555+5656+ err ->
5757+ err
5858+ end
5959+ end
6060+6161+ @doc """
6262+ Create an OAuthClient from a `Plug.Conn`.
6363+6464+ Extracts the DID from the session (stored under `:atex_session` key) and validates
6565+ that the OAuth session is still valid. If the token is expired or expiring soon,
6666+ it attempts to refresh it.
6767+6868+ Requires the conn to have passed through `Plug.Session` and `Plug.Conn.fetch_session/2`.
6969+7070+ ## Returns
7171+7272+ - `{:ok, client}` - Successfully created client
7373+ - `{:error, :reauth}` - Session exists but refresh failed, user needs to re-authenticate
7474+ - `:error` - No session found in conn
7575+7676+ ## Examples
7777+7878+ # After OAuth flow completes
7979+ conn = Plug.Conn.put_session(conn, :atex_session, "did:plc:abc123")
8080+ {:ok, client} = Atex.XRPC.OAuthClient.from_conn(conn)
8181+8282+ """
8383+ @spec from_conn(Plug.Conn.t()) :: {:ok, t()} | :error | {:error, atom()}
8484+ def from_conn(%Plug.Conn{} = conn) do
8585+ oauth_did = Plug.Conn.get_session(conn, :atex_session)
8686+8787+ case oauth_did do
8888+ did when is_binary(did) ->
8989+ client = %__MODULE__{did: did}
9090+9191+ with_session_lock(client, fn ->
9292+ case maybe_refresh(client) do
9393+ {:ok, _session} -> {:ok, client}
9494+ _ -> {:error, :reauth}
9595+ end
9696+ end)
9797+9898+ _ ->
9999+ :error
100100+ end
101101+ end
102102+103103+ @doc """
104104+ Ask the client's OAuth server for a new set of auth tokens.
105105+106106+ Fetches the session, refreshes the tokens, creates a new session with the
107107+ updated tokens, stores it, and returns the new session.
108108+109109+ You shouldn't need to call this manually for the most part, the client does
110110+ its best to refresh automatically when it needs to.
111111+112112+ This function acquires a lock on the session to prevent concurrent refresh attempts.
113113+ """
114114+ @spec refresh(client :: t()) :: {:ok, OAuth.Session.t()} | {:error, any()}
115115+ def refresh(%__MODULE__{} = client) do
116116+ with_session_lock(client, fn ->
117117+ do_refresh(client)
118118+ end)
119119+ end
120120+121121+ @spec do_refresh(t()) :: {:ok, OAuth.Session.t()} | {:error, any()}
122122+ defp do_refresh(%__MODULE__{did: did}) do
123123+ with {:ok, session} <- OAuth.SessionStore.get(did),
124124+ {:ok, authz_server} <- OAuth.get_authorization_server(session.aud),
125125+ {:ok, %{token_endpoint: token_endpoint}} <-
126126+ OAuth.get_authorization_server_metadata(authz_server) do
127127+ case OAuth.refresh_token(
128128+ session.refresh_token,
129129+ session.dpop_key,
130130+ session.iss,
131131+ token_endpoint
132132+ ) do
133133+ {:ok, tokens, nonce} ->
134134+ new_session = %OAuth.Session{
135135+ iss: session.iss,
136136+ aud: session.aud,
137137+ sub: tokens.did,
138138+ access_token: tokens.access_token,
139139+ refresh_token: tokens.refresh_token,
140140+ expires_at: tokens.expires_at,
141141+ dpop_key: session.dpop_key,
142142+ dpop_nonce: nonce
143143+ }
144144+145145+ case OAuth.SessionStore.update(new_session) do
146146+ :ok -> {:ok, new_session}
147147+ err -> err
148148+ end
149149+150150+ err ->
151151+ err
152152+ end
153153+ end
154154+ end
155155+156156+ @spec maybe_refresh(t(), integer()) :: {:ok, OAuth.Session.t()} | {:error, any()}
157157+ defp maybe_refresh(%__MODULE__{did: did} = client, buffer_minutes \\ 5) do
158158+ with {:ok, session} <- OAuth.SessionStore.get(did) do
159159+ if token_expiring_soon?(session.expires_at, buffer_minutes) do
160160+ do_refresh(client)
161161+ else
162162+ {:ok, session}
163163+ end
164164+ end
165165+ end
166166+167167+ @spec token_expiring_soon?(NaiveDateTime.t(), integer()) :: boolean()
168168+ defp token_expiring_soon?(expires_at, buffer_minutes) do
169169+ now = NaiveDateTime.utc_now()
170170+ expiry_threshold = NaiveDateTime.add(now, buffer_minutes * 60, :second)
171171+172172+ NaiveDateTime.compare(expires_at, expiry_threshold) in [:lt, :eq]
173173+ end
174174+175175+ @doc """
176176+ Make a GET request to an XRPC endpoint.
177177+178178+ See `Atex.XRPC.get/3` for details.
179179+ """
180180+ @impl true
181181+ def get(%__MODULE__{} = client, resource, opts \\ []) do
182182+ # TODO: Keyword.valiate to make sure :method isn't passed?
183183+ request(client, resource, opts ++ [method: :get])
184184+ end
185185+186186+ @doc """
187187+ Make a POST request to an XRPC endpoint.
188188+189189+ See `Atex.XRPC.post/3` for details.
190190+ """
191191+ @impl true
192192+ def post(%__MODULE__{} = client, resource, opts \\ []) do
193193+ # Ditto
194194+ request(client, resource, opts ++ [method: :post])
195195+ end
196196+197197+ defp request(%__MODULE__{} = client, resource, opts) do
198198+ with_session_lock(client, fn ->
199199+ case maybe_refresh(client) do
200200+ {:ok, session} ->
201201+ url = Atex.XRPC.url(session.aud, resource)
202202+203203+ request =
204204+ opts
205205+ |> Keyword.put(:url, url)
206206+ |> Req.new()
207207+ |> Req.Request.put_header("authorization", "DPoP #{session.access_token}")
208208+209209+ case OAuth.request_protected_dpop_resource(
210210+ request,
211211+ session.iss,
212212+ session.access_token,
213213+ session.dpop_key,
214214+ session.dpop_nonce
215215+ ) do
216216+ {:ok, %{status: 200} = response, nonce} ->
217217+ update_session_nonce(session, nonce)
218218+ {:ok, response, client}
219219+220220+ {:ok, response, nonce} ->
221221+ update_session_nonce(session, nonce)
222222+ handle_failure(client, request, response)
223223+224224+ err ->
225225+ err
226226+ end
227227+228228+ err ->
229229+ err
230230+ end
231231+ end)
232232+ end
233233+234234+ # Execute a function with an exclusive lock on the session identified by the
235235+ # client's DID. This ensures that concurrent requests for the same user don't
236236+ # race during token refresh.
237237+ @spec with_session_lock(t(), (-> result)) :: result when result: any()
238238+ defp with_session_lock(%__MODULE__{did: did}, fun) do
239239+ Mutex.with_lock(Atex.SessionMutex, did, fun)
240240+ end
241241+242242+ defp handle_failure(client, request, response) do
243243+ if auth_error?(response) do
244244+ case do_refresh(client) do
245245+ {:ok, session} ->
246246+ case OAuth.request_protected_dpop_resource(
247247+ request,
248248+ session.iss,
249249+ session.access_token,
250250+ session.dpop_key,
251251+ session.dpop_nonce
252252+ ) do
253253+ {:ok, %{status: 200} = response, nonce} ->
254254+ update_session_nonce(session, nonce)
255255+ {:ok, response, client}
256256+257257+ {:ok, response, _nonce} ->
258258+ if auth_error?(response) do
259259+ # We tried to refresh the token once but it's still failing
260260+ # Clear session and prompt dev to reauth or something
261261+ OAuth.SessionStore.delete(session)
262262+ {:error, response, :expired}
263263+ else
264264+ {:error, response, client}
265265+ end
266266+267267+ err ->
268268+ err
269269+ end
270270+271271+ err ->
272272+ err
273273+ end
274274+ else
275275+ {:error, response, client}
276276+ end
277277+ end
278278+279279+ @spec auth_error?(Req.Response.t()) :: boolean()
280280+ defp auth_error?(%{status: 401, headers: %{"www-authenticate" => [www_auth]}}),
281281+ do:
282282+ (String.starts_with?(www_auth, "Bearer") or String.starts_with?(www_auth, "DPoP")) and
283283+ String.contains?(www_auth, "error=\"invalid_token\"")
284284+285285+ defp auth_error?(_resp), do: false
286286+287287+ defp update_session_nonce(session, nonce) do
288288+ session = %{session | dpop_nonce: nonce}
289289+ :ok = OAuth.SessionStore.update(session)
290290+ session
291291+ end
292292+end
+210
lib/atex/xrpc.ex
···11+defmodule Atex.XRPC do
22+ @moduledoc """
33+ XRPC client module for AT Protocol RPC calls.
44+55+ This module provides both authenticated and unauthenticated access to AT Protocol
66+ XRPC endpoints. The authenticated functions (`get/3`, `post/3`) work with any
77+ client that implements the `Atex.XRPC.Client`.
88+99+ ## Example usage
1010+1111+ # Login-based client
1212+ {:ok, client} = Atex.XRPC.LoginClient.login("https://bsky.social", "user.bsky.social", "password")
1313+ {:ok, response, client} = Atex.XRPC.get(client, "app.bsky.actor.getProfile", params: [actor: "user.bsky.social"])
1414+1515+ # OAuth-based client
1616+ {:ok, oauth_client} = Atex.XRPC.OAuthClient.from_conn(conn)
1717+ {:ok, response, oauth_client} = Atex.XRPC.get(oauth_client, "app.bsky.actor.getProfile", params: [actor: "user.bsky.social"])
1818+1919+ ## Unauthenticated requests
2020+2121+ Unauthenticated functions (`unauthed_get/3`, `unauthed_post/3`) do not require a client
2222+ and work directly with endpoints:
2323+2424+ {:ok, response} = Atex.XRPC.unauthed_get("https://bsky.social", "com.atproto.sync.getHead", params: [did: "did:plc:..."])
2525+ """
2626+2727+ alias Atex.XRPC.Client
2828+2929+ @doc """
3030+ Perform a HTTP GET on a XRPC resource. Called a "query" in lexicons.
3131+3232+ Accepts any client that implements `Atex.XRPC.Client` and returns
3333+ both the response and the (potentially updated) client.
3434+3535+ Can be called either with the XRPC operation name as a string, or with a lexicon
3636+ struct (generated via `deflexicon`) for type safety and automatic parameter/response handling.
3737+3838+ When using a lexicon struct, the response body will be automatically converted to the
3939+ corresponding type if an Output struct exists for the lexicon.
4040+4141+ ## Examples
4242+4343+ # Using string XRPC name
4444+ {:ok, response, client} =
4545+ Atex.XRPC.get(client, "app.bsky.actor.getProfile", params: [actor: "ovyerus.com"])
4646+4747+ # Using lexicon struct with typed construction
4848+ {:ok, response, client} =
4949+ Atex.XRPC.get(client, %App.Bsky.Actor.GetProfile{
5050+ params: %App.Bsky.Actor.GetProfile.Params{actor: "ovyerus.com"}
5151+ })
5252+ """
5353+ @spec get(Client.client(), String.t() | struct(), keyword()) ::
5454+ {:ok, Req.Response.t(), Client.client()}
5555+ | {:error, any(), Client.client()}
5656+ def get(client, name, opts \\ [])
5757+5858+ def get(client, name, opts) when is_binary(name) do
5959+ client.__struct__.get(client, name, opts)
6060+ end
6161+6262+ def get(client, %{__struct__: module} = query, opts) do
6363+ opts = if Map.get(query, :params), do: Keyword.put(opts, :params, query.params), else: opts
6464+ output_struct = Module.concat(module, Output)
6565+ output_exists = Code.ensure_loaded?(output_struct)
6666+6767+ case client.__struct__.get(client, module.id(), opts) do
6868+ {:ok, %{status: 200} = response, client} ->
6969+ if output_exists do
7070+ case output_struct.from_json(response.body) do
7171+ {:ok, output} ->
7272+ {:ok, %{response | body: output}, client}
7373+7474+ err ->
7575+ err
7676+ end
7777+ else
7878+ {:ok, response, client}
7979+ end
8080+8181+ {:ok, _, _} = ok ->
8282+ ok
8383+8484+ err ->
8585+ err
8686+ end
8787+ end
8888+8989+ @doc """
9090+ Perform a HTTP POST on a XRPC resource. Called a "procedure" in lexicons.
9191+9292+ Accepts any client that implements `Atex.XRPC.Client` and returns both the
9393+ response and the (potentially updated) client.
9494+9595+ Can be called either with the XRPC operation name as a string, or with a
9696+ lexicon struct (generated via `deflexicon`) for type safety and automatic
9797+ input/parameter mapping.
9898+9999+ When using a lexicon struct, the response body will be automatically converted
100100+ to the corresponding type if an Output struct exists for the lexicon.
101101+102102+ ## Examples
103103+104104+ # Using string XRPC name
105105+ {:ok, response, client} =
106106+ Atex.XRPC.post(
107107+ client,
108108+ "com.atproto.repo.createRecord",
109109+ json: %{
110110+ repo: "did:plc:...",
111111+ collection: "app.bsky.feed.post",
112112+ rkey: Atex.TID.now() |> to_string(),
113113+ record: %{
114114+ text: "Hello World",
115115+ createdAt: DateTime.to_iso8601(DateTime.utc_now())
116116+ }
117117+ }
118118+ )
119119+120120+ # Using lexicon struct with typed construction
121121+ {:ok, response, client} =
122122+ Atex.XRPC.post(client, %Com.Atproto.Repo.CreateRecord{
123123+ input: %Com.Atproto.Repo.CreateRecord.Input{
124124+ repo: "did:plc:...",
125125+ collection: "app.bsky.feed.post",
126126+ rkey: Atex.TID.now() |> to_string(),
127127+ record: %App.Bsky.Feed.Post{
128128+ text: "Hello World!",
129129+ createdAt: DateTime.to_iso8601(DateTime.utc_now())
130130+ }
131131+ }
132132+ })
133133+ """
134134+ @spec post(Client.client(), String.t() | struct(), keyword()) ::
135135+ {:ok, Req.Response.t(), Client.client()}
136136+ | {:error, any(), Client.client()}
137137+ def post(client, name, opts \\ [])
138138+139139+ def post(client, name, opts) when is_binary(name) do
140140+ client.__struct__.post(client, name, opts)
141141+ end
142142+143143+ def post(client, %{__struct__: module} = procedure, opts) do
144144+ opts =
145145+ opts
146146+ |> then(
147147+ &if Map.get(procedure, :params), do: Keyword.put(&1, :params, procedure.params), else: &1
148148+ )
149149+ |> then(
150150+ &cond do
151151+ Map.get(procedure, :input) -> Keyword.put(&1, :json, procedure.input)
152152+ Map.get(procedure, :raw_input) -> Keyword.put(&1, :body, procedure.raw_input)
153153+ true -> &1
154154+ end
155155+ )
156156+157157+ output_struct = Module.concat(module, Output)
158158+ output_exists = Code.ensure_loaded?(output_struct)
159159+160160+ case client.__struct__.post(client, module.id(), opts) do
161161+ {:ok, %{status: 200} = response, client} ->
162162+ if output_exists do
163163+ case output_struct.from_json(response.body) do
164164+ {:ok, output} ->
165165+ {:ok, %{response | body: output}, client}
166166+167167+ err ->
168168+ err
169169+ end
170170+ else
171171+ {:ok, response, client}
172172+ end
173173+174174+ {:ok, _, _} = ok ->
175175+ ok
176176+177177+ err ->
178178+ err
179179+ end
180180+ end
181181+182182+ @doc """
183183+ Like `get/3` but is unauthenticated by default.
184184+ """
185185+ @spec unauthed_get(String.t(), String.t(), keyword()) ::
186186+ {:ok, Req.Response.t()} | {:error, any()}
187187+ def unauthed_get(endpoint, name, opts \\ []) do
188188+ Req.get(url(endpoint, name), opts)
189189+ end
190190+191191+ @doc """
192192+ Like `post/3` but is unauthenticated by default.
193193+ """
194194+ @spec unauthed_post(String.t(), String.t(), keyword()) ::
195195+ {:ok, Req.Response.t()} | {:error, any()}
196196+ def unauthed_post(endpoint, name, opts \\ []) do
197197+ Req.post(url(endpoint, name), opts)
198198+ end
199199+200200+ @doc """
201201+ Create an XRPC url based on an endpoint and a resource name.
202202+203203+ ## Example
204204+205205+ iex> Atex.XRPC.url("https://bsky.app", "app.bsky.actor.getProfile")
206206+ "https://bsky.app/xrpc/app.bsky.actor.getProfile"
207207+ """
208208+ @spec url(String.t(), String.t()) :: String.t()
209209+ def url(endpoint, resource) when is_binary(endpoint), do: "#{endpoint}/xrpc/#{resource}"
210210+end
···11+defmodule Com.Atproto.Lexicon.Schema do
22+ @moduledoc false
33+ use Atex.Lexicon
44+55+ deflexicon(%{
66+ "defs" => %{
77+ "main" => %{
88+ "description" =>
99+ "Representation of Lexicon schemas themselves, when published as atproto records. Note that the schema language is not defined in Lexicon; this meta schema currently only includes a single version field ('lexicon'). See the atproto specifications for description of the other expected top-level fields ('id', 'defs', etc).",
1010+ "key" => "nsid",
1111+ "record" => %{
1212+ "properties" => %{
1313+ "lexicon" => %{
1414+ "description" =>
1515+ "Indicates the 'version' of the Lexicon language. Must be '1' for the current atproto/Lexicon schema system.",
1616+ "type" => "integer"
1717+ }
1818+ },
1919+ "required" => ["lexicon"],
2020+ "type" => "object"
2121+ },
2222+ "type" => "record"
2323+ }
2424+ },
2525+ "id" => "com.atproto.lexicon.schema",
2626+ "lexicon" => 1
2727+ })
2828+end
···11+defmodule Com.Atproto.Server.DeactivateAccount do
22+ @moduledoc false
33+ use Atex.Lexicon
44+55+ deflexicon(%{
66+ "defs" => %{
77+ "main" => %{
88+ "description" =>
99+ "Deactivates a currently active account. Stops serving of repo, and future writes to repo until reactivated. Used to finalize account migration with the old host after the account has been activated on the new host.",
1010+ "input" => %{
1111+ "encoding" => "application/json",
1212+ "schema" => %{
1313+ "properties" => %{
1414+ "deleteAfter" => %{
1515+ "description" =>
1616+ "A recommendation to server as to how long they should hold onto the deactivated account before deleting.",
1717+ "format" => "datetime",
1818+ "type" => "string"
1919+ }
2020+ },
2121+ "type" => "object"
2222+ }
2323+ },
2424+ "type" => "procedure"
2525+ }
2626+ },
2727+ "id" => "com.atproto.server.deactivateAccount",
2828+ "lexicon" => 1
2929+ })
3030+end
···11+defmodule Com.Atproto.Server.GetServiceAuth do
22+ @moduledoc false
33+ use Atex.Lexicon
44+55+ deflexicon(%{
66+ "defs" => %{
77+ "main" => %{
88+ "description" =>
99+ "Get a signed token on behalf of the requesting DID for the requested service.",
1010+ "errors" => [
1111+ %{
1212+ "description" =>
1313+ "Indicates that the requested expiration date is not a valid. May be in the past or may be reliant on the requested scopes.",
1414+ "name" => "BadExpiration"
1515+ }
1616+ ],
1717+ "output" => %{
1818+ "encoding" => "application/json",
1919+ "schema" => %{
2020+ "properties" => %{"token" => %{"type" => "string"}},
2121+ "required" => ["token"],
2222+ "type" => "object"
2323+ }
2424+ },
2525+ "parameters" => %{
2626+ "properties" => %{
2727+ "aud" => %{
2828+ "description" =>
2929+ "The DID of the service that the token will be used to authenticate with",
3030+ "format" => "did",
3131+ "type" => "string"
3232+ },
3333+ "exp" => %{
3434+ "description" =>
3535+ "The time in Unix Epoch seconds that the JWT expires. Defaults to 60 seconds in the future. The service may enforce certain time bounds on tokens depending on the requested scope.",
3636+ "type" => "integer"
3737+ },
3838+ "lxm" => %{
3939+ "description" => "Lexicon (XRPC) method to bind the requested token to",
4040+ "format" => "nsid",
4141+ "type" => "string"
4242+ }
4343+ },
4444+ "required" => ["aud"],
4545+ "type" => "params"
4646+ },
4747+ "type" => "query"
4848+ }
4949+ },
5050+ "id" => "com.atproto.server.getServiceAuth",
5151+ "lexicon" => 1
5252+ })
5353+end
+37
lib/atproto/com/atproto/server/getSession.ex
···11+defmodule Com.Atproto.Server.GetSession do
22+ @moduledoc false
33+ use Atex.Lexicon
44+55+ deflexicon(%{
66+ "defs" => %{
77+ "main" => %{
88+ "description" => "Get information about the current auth session. Requires auth.",
99+ "output" => %{
1010+ "encoding" => "application/json",
1111+ "schema" => %{
1212+ "properties" => %{
1313+ "active" => %{"type" => "boolean"},
1414+ "did" => %{"format" => "did", "type" => "string"},
1515+ "didDoc" => %{"type" => "unknown"},
1616+ "email" => %{"type" => "string"},
1717+ "emailAuthFactor" => %{"type" => "boolean"},
1818+ "emailConfirmed" => %{"type" => "boolean"},
1919+ "handle" => %{"format" => "handle", "type" => "string"},
2020+ "status" => %{
2121+ "description" =>
2222+ "If active=false, this optional field indicates a possible reason for why the account is not active. If active=false and no status is supplied, then the host makes no claim for why the repository is no longer being hosted.",
2323+ "knownValues" => ["takendown", "suspended", "deactivated"],
2424+ "type" => "string"
2525+ }
2626+ },
2727+ "required" => ["handle", "did"],
2828+ "type" => "object"
2929+ }
3030+ },
3131+ "type" => "query"
3232+ }
3333+ },
3434+ "id" => "com.atproto.server.getSession",
3535+ "lexicon" => 1
3636+ })
3737+end
···11+defmodule Com.Atproto.Sync.NotifyOfUpdate do
22+ @moduledoc false
33+ use Atex.Lexicon
44+55+ deflexicon(%{
66+ "defs" => %{
77+ "main" => %{
88+ "description" =>
99+ "Notify a crawling service of a recent update, and that crawling should resume. Intended use is after a gap between repo stream events caused the crawling service to disconnect. Does not require auth; implemented by Relay. DEPRECATED: just use com.atproto.sync.requestCrawl",
1010+ "input" => %{
1111+ "encoding" => "application/json",
1212+ "schema" => %{
1313+ "properties" => %{
1414+ "hostname" => %{
1515+ "description" =>
1616+ "Hostname of the current service (usually a PDS) that is notifying of update.",
1717+ "type" => "string"
1818+ }
1919+ },
2020+ "required" => ["hostname"],
2121+ "type" => "object"
2222+ }
2323+ },
2424+ "type" => "procedure"
2525+ }
2626+ },
2727+ "id" => "com.atproto.sync.notifyOfUpdate",
2828+ "lexicon" => 1
2929+ })
3030+end
+31
lib/atproto/com/atproto/sync/requestCrawl.ex
···11+defmodule Com.Atproto.Sync.RequestCrawl do
22+ @moduledoc false
33+ use Atex.Lexicon
44+55+ deflexicon(%{
66+ "defs" => %{
77+ "main" => %{
88+ "description" =>
99+ "Request a service to persistently crawl hosted repos. Expected use is new PDS instances declaring their existence to Relays. Does not require auth.",
1010+ "errors" => [%{"name" => "HostBanned"}],
1111+ "input" => %{
1212+ "encoding" => "application/json",
1313+ "schema" => %{
1414+ "properties" => %{
1515+ "hostname" => %{
1616+ "description" =>
1717+ "Hostname of the current service (eg, PDS) that is requesting to be crawled.",
1818+ "type" => "string"
1919+ }
2020+ },
2121+ "required" => ["hostname"],
2222+ "type" => "object"
2323+ }
2424+ },
2525+ "type" => "procedure"
2626+ }
2727+ },
2828+ "id" => "com.atproto.sync.requestCrawl",
2929+ "lexicon" => 1
3030+ })
3131+end
+238
lib/atproto/com/atproto/sync/subscribeRepos.ex
···11+defmodule Com.Atproto.Sync.SubscribeRepos do
22+ @moduledoc false
33+ use Atex.Lexicon
44+55+ deflexicon(%{
66+ "defs" => %{
77+ "account" => %{
88+ "description" =>
99+ "Represents a change to an account's status on a host (eg, PDS or Relay). The semantics of this event are that the status is at the host which emitted the event, not necessarily that at the currently active PDS. Eg, a Relay takedown would emit a takedown with active=false, even if the PDS is still active.",
1010+ "properties" => %{
1111+ "active" => %{
1212+ "description" =>
1313+ "Indicates that the account has a repository which can be fetched from the host that emitted this event.",
1414+ "type" => "boolean"
1515+ },
1616+ "did" => %{"format" => "did", "type" => "string"},
1717+ "seq" => %{"type" => "integer"},
1818+ "status" => %{
1919+ "description" =>
2020+ "If active=false, this optional field indicates a reason for why the account is not active.",
2121+ "knownValues" => [
2222+ "takendown",
2323+ "suspended",
2424+ "deleted",
2525+ "deactivated",
2626+ "desynchronized",
2727+ "throttled"
2828+ ],
2929+ "type" => "string"
3030+ },
3131+ "time" => %{"format" => "datetime", "type" => "string"}
3232+ },
3333+ "required" => ["seq", "did", "time", "active"],
3434+ "type" => "object"
3535+ },
3636+ "commit" => %{
3737+ "description" =>
3838+ "Represents an update of repository state. Note that empty commits are allowed, which include no repo data changes, but an update to rev and signature.",
3939+ "nullable" => ["since"],
4040+ "properties" => %{
4141+ "blobs" => %{
4242+ "items" => %{
4343+ "description" =>
4444+ "DEPRECATED -- will soon always be empty. List of new blobs (by CID) referenced by records in this commit.",
4545+ "type" => "cid-link"
4646+ },
4747+ "type" => "array"
4848+ },
4949+ "blocks" => %{
5050+ "description" =>
5151+ "CAR file containing relevant blocks, as a diff since the previous repo state. The commit must be included as a block, and the commit block CID must be the first entry in the CAR header 'roots' list.",
5252+ "maxLength" => 2_000_000,
5353+ "type" => "bytes"
5454+ },
5555+ "commit" => %{
5656+ "description" => "Repo commit object CID.",
5757+ "type" => "cid-link"
5858+ },
5959+ "ops" => %{
6060+ "items" => %{
6161+ "description" =>
6262+ "List of repo mutation operations in this commit (eg, records created, updated, or deleted).",
6363+ "ref" => "#repoOp",
6464+ "type" => "ref"
6565+ },
6666+ "maxLength" => 200,
6767+ "type" => "array"
6868+ },
6969+ "prevData" => %{
7070+ "description" =>
7171+ "The root CID of the MST tree for the previous commit from this repo (indicated by the 'since' revision field in this message). Corresponds to the 'data' field in the repo commit object. NOTE: this field is effectively required for the 'inductive' version of firehose.",
7272+ "type" => "cid-link"
7373+ },
7474+ "rebase" => %{
7575+ "description" => "DEPRECATED -- unused",
7676+ "type" => "boolean"
7777+ },
7878+ "repo" => %{
7979+ "description" =>
8080+ "The repo this event comes from. Note that all other message types name this field 'did'.",
8181+ "format" => "did",
8282+ "type" => "string"
8383+ },
8484+ "rev" => %{
8585+ "description" =>
8686+ "The rev of the emitted commit. Note that this information is also in the commit object included in blocks, unless this is a tooBig event.",
8787+ "format" => "tid",
8888+ "type" => "string"
8989+ },
9090+ "seq" => %{
9191+ "description" => "The stream sequence number of this message.",
9292+ "type" => "integer"
9393+ },
9494+ "since" => %{
9595+ "description" => "The rev of the last emitted commit from this repo (if any).",
9696+ "format" => "tid",
9797+ "type" => "string"
9898+ },
9999+ "time" => %{
100100+ "description" => "Timestamp of when this message was originally broadcast.",
101101+ "format" => "datetime",
102102+ "type" => "string"
103103+ },
104104+ "tooBig" => %{
105105+ "description" =>
106106+ "DEPRECATED -- replaced by #sync event and data limits. Indicates that this commit contained too many ops, or data size was too large. Consumers will need to make a separate request to get missing data.",
107107+ "type" => "boolean"
108108+ }
109109+ },
110110+ "required" => [
111111+ "seq",
112112+ "rebase",
113113+ "tooBig",
114114+ "repo",
115115+ "commit",
116116+ "rev",
117117+ "since",
118118+ "blocks",
119119+ "ops",
120120+ "blobs",
121121+ "time"
122122+ ],
123123+ "type" => "object"
124124+ },
125125+ "identity" => %{
126126+ "description" =>
127127+ "Represents a change to an account's identity. Could be an updated handle, signing key, or pds hosting endpoint. Serves as a prod to all downstream services to refresh their identity cache.",
128128+ "properties" => %{
129129+ "did" => %{"format" => "did", "type" => "string"},
130130+ "handle" => %{
131131+ "description" =>
132132+ "The current handle for the account, or 'handle.invalid' if validation fails. This field is optional, might have been validated or passed-through from an upstream source. Semantics and behaviors for PDS vs Relay may evolve in the future; see atproto specs for more details.",
133133+ "format" => "handle",
134134+ "type" => "string"
135135+ },
136136+ "seq" => %{"type" => "integer"},
137137+ "time" => %{"format" => "datetime", "type" => "string"}
138138+ },
139139+ "required" => ["seq", "did", "time"],
140140+ "type" => "object"
141141+ },
142142+ "info" => %{
143143+ "properties" => %{
144144+ "message" => %{"type" => "string"},
145145+ "name" => %{"knownValues" => ["OutdatedCursor"], "type" => "string"}
146146+ },
147147+ "required" => ["name"],
148148+ "type" => "object"
149149+ },
150150+ "main" => %{
151151+ "description" =>
152152+ "Repository event stream, aka Firehose endpoint. Outputs repo commits with diff data, and identity update events, for all repositories on the current server. See the atproto specifications for details around stream sequencing, repo versioning, CAR diff format, and more. Public and does not require auth; implemented by PDS and Relay.",
153153+ "errors" => [
154154+ %{"name" => "FutureCursor"},
155155+ %{
156156+ "description" =>
157157+ "If the consumer of the stream can not keep up with events, and a backlog gets too large, the server will drop the connection.",
158158+ "name" => "ConsumerTooSlow"
159159+ }
160160+ ],
161161+ "message" => %{
162162+ "schema" => %{
163163+ "refs" => ["#commit", "#sync", "#identity", "#account", "#info"],
164164+ "type" => "union"
165165+ }
166166+ },
167167+ "parameters" => %{
168168+ "properties" => %{
169169+ "cursor" => %{
170170+ "description" => "The last known event seq number to backfill from.",
171171+ "type" => "integer"
172172+ }
173173+ },
174174+ "type" => "params"
175175+ },
176176+ "type" => "subscription"
177177+ },
178178+ "repoOp" => %{
179179+ "description" => "A repo operation, ie a mutation of a single record.",
180180+ "nullable" => ["cid"],
181181+ "properties" => %{
182182+ "action" => %{
183183+ "knownValues" => ["create", "update", "delete"],
184184+ "type" => "string"
185185+ },
186186+ "cid" => %{
187187+ "description" => "For creates and updates, the new record CID. For deletions, null.",
188188+ "type" => "cid-link"
189189+ },
190190+ "path" => %{"type" => "string"},
191191+ "prev" => %{
192192+ "description" =>
193193+ "For updates and deletes, the previous record CID (required for inductive firehose). For creations, field should not be defined.",
194194+ "type" => "cid-link"
195195+ }
196196+ },
197197+ "required" => ["action", "path", "cid"],
198198+ "type" => "object"
199199+ },
200200+ "sync" => %{
201201+ "description" =>
202202+ "Updates the repo to a new state, without necessarily including that state on the firehose. Used to recover from broken commit streams, data loss incidents, or in situations where upstream host does not know recent state of the repository.",
203203+ "properties" => %{
204204+ "blocks" => %{
205205+ "description" =>
206206+ "CAR file containing the commit, as a block. The CAR header must include the commit block CID as the first 'root'.",
207207+ "maxLength" => 10000,
208208+ "type" => "bytes"
209209+ },
210210+ "did" => %{
211211+ "description" =>
212212+ "The account this repo event corresponds to. Must match that in the commit object.",
213213+ "format" => "did",
214214+ "type" => "string"
215215+ },
216216+ "rev" => %{
217217+ "description" =>
218218+ "The rev of the commit. This value must match that in the commit object.",
219219+ "type" => "string"
220220+ },
221221+ "seq" => %{
222222+ "description" => "The stream sequence number of this message.",
223223+ "type" => "integer"
224224+ },
225225+ "time" => %{
226226+ "description" => "Timestamp of when this message was originally broadcast.",
227227+ "format" => "datetime",
228228+ "type" => "string"
229229+ }
230230+ },
231231+ "required" => ["seq", "did", "blocks", "rev", "time"],
232232+ "type" => "object"
233233+ }
234234+ },
235235+ "id" => "com.atproto.sync.subscribeRepos",
236236+ "lexicon" => 1
237237+ })
238238+end
-159
lib/aturi.ex
···11-defmodule Atex.AtURI do
22- @moduledoc """
33- Struct and helper functions for manipulating `at://` URIs, which identify
44- specific records within the AT Protocol.
55-66- ATProto spec: https://atproto.com/specs/at-uri-scheme
77-88- This module only supports the restricted URI syntax used for the Lexicon
99- `at-uri` type, with no support for query strings or fragments. If/when the
1010- full syntax gets widespread use, this module will expand to accomodate them.
1111-1212- Both URIs using DIDs and handles ("example.com") are supported.
1313- """
1414-1515- use TypedStruct
1616-1717- @did ~S"did:(?:plc|web):[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]"
1818- @handle ~S"(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?"
1919- @nsid ~S"[a-zA-Z](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?:\.[a-zA-Z](?:[a-zA-Z0-9]{0,62})?)"
2020-2121- @authority "(?<authority>(?:#{@did})|(?:#{@handle}))"
2222- @collection "(?<collection>#{@nsid})"
2323- @rkey "(?<rkey>[a-zA-Z0-9.-_:~]{1,512})"
2424-2525- @re ~r"^at://#{@authority}(?:/#{@collection}(?:/#{@rkey})?)?$"
2626-2727- typedstruct do
2828- field :authority, String.t(), enforce: true
2929- field :collection, String.t() | nil
3030- field :rkey, String.t() | nil
3131- end
3232-3333- @doc """
3434- Create a new AtURI struct from a string by matching it against the regex.
3535-3636- Returns `{:ok, aturi}` if a valid `at://` URI is given, otherwise it will return `:error`.
3737-3838- ## Examples
3939-4040- iex> Atex.AtURI.new("at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26")
4141- {:ok, %Atex.AtURI{
4242- rkey: "3jwdwj2ctlk26",
4343- collection: "app.bsky.feed.post",
4444- authority: "did:plc:44ybard66vv44zksje25o7dz"
4545- }}
4646-4747- iex> Atex.AtURI.new("at:invalid/malformed")
4848- :error
4949-5050- Partial URIs pointing to a collection without a record key, or even just a given authority, are also supported:
5151-5252- iex> Atex.AtURI.new("at://ovyerus.com/sh.comet.v0.feed.track")
5353- {:ok, %Atex.AtURI{
5454- rkey: nil,
5555- collection: "sh.comet.v0.feed.track",
5656- authority: "ovyerus.com"
5757- }}
5858-5959- iex> Atex.AtURI.new("at://did:web:comet.sh")
6060- {:ok, %Atex.AtURI{
6161- rkey: nil,
6262- collection: nil,
6363- authority: "did:web:comet.sh"
6464- }}
6565- """
6666- @spec new(String.t()) :: {:ok, t()} | :error
6767- def new(string) when is_binary(string) do
6868- # TODO: test different ways to get a good error from regex on which part failed match?
6969- case Regex.named_captures(@re, string) do
7070- %{} = captures -> {:ok, from_named_captures(captures)}
7171- nil -> :error
7272- end
7373- end
7474-7575- @doc """
7676- The same as `new/1` but raises an `ArgumentError` if an invalid string is given.
7777-7878- ## Examples
7979-8080- iex> Atex.AtURI.new!("at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26")
8181- %Atex.AtURI{
8282- rkey: "3jwdwj2ctlk26",
8383- collection: "app.bsky.feed.post",
8484- authority: "did:plc:44ybard66vv44zksje25o7dz"
8585- }
8686-8787- iex> Atex.AtURI.new!("at:invalid/malformed")
8888- ** (ArgumentError) Malformed at:// URI
8989- """
9090- @spec new!(String.t()) :: t()
9191- def new!(string) when is_binary(string) do
9292- case new(string) do
9393- {:ok, uri} -> uri
9494- :error -> raise ArgumentError, message: "Malformed at:// URI"
9595- end
9696- end
9797-9898- @doc """
9999- Check if a string is a valid `at://` URI.
100100-101101- ## Examples
102102-103103- iex> Atex.AtURI.match?("at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26")
104104- true
105105-106106- iex> Atex.AtURI.match?("at://did:web:comet.sh")
107107- true
108108-109109- iex> Atex.AtURI.match?("at://ovyerus.com/sh.comet.v0.feed.track")
110110- true
111111-112112- iex> Atex.AtURI.match?("gobbledy gook")
113113- false
114114- """
115115- @spec match?(String.t()) :: boolean()
116116- def match?(string), do: Regex.match?(@re, string)
117117-118118- @doc """
119119- Format an `Atex.AtURI` to the canonical string representation.
120120-121121- Also available via the `String.Chars` protocol.
122122-123123- ## Examples
124124-125125- iex> aturi = %Atex.AtURI{
126126- ...> rkey: "3jwdwj2ctlk26",
127127- ...> collection: "app.bsky.feed.post",
128128- ...> authority: "did:plc:44ybard66vv44zksje25o7dz"
129129- ...> }
130130- iex> Atex.AtURI.to_string(aturi)
131131- "at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26"
132132-133133- iex> aturi = %Atex.AtURI{authority: "did:web:comet.sh"}
134134- iex> to_string(aturi)
135135- "at://did:web:comet.sh"
136136- """
137137- @spec to_string(t()) :: String.t()
138138- def to_string(%__MODULE__{} = uri) do
139139- "at://#{uri.authority}/#{uri.collection}/#{uri.rkey}"
140140- |> String.trim_trailing("/")
141141- end
142142-143143- defp from_named_captures(%{"authority" => authority, "collection" => "", "rkey" => ""}),
144144- do: %__MODULE__{authority: authority}
145145-146146- defp from_named_captures(%{"authority" => authority, "collection" => collection, "rkey" => ""}),
147147- do: %__MODULE__{authority: authority, collection: collection}
148148-149149- defp from_named_captures(%{
150150- "authority" => authority,
151151- "collection" => collection,
152152- "rkey" => rkey
153153- }),
154154- do: %__MODULE__{authority: authority, collection: collection, rkey: rkey}
155155-end
156156-157157-defimpl String.Chars, for: Atex.AtURI do
158158- def to_string(uri), do: Atex.AtURI.to_string(uri)
159159-end
-39
lib/base32_sortable.ex
···11-defmodule Atex.Base32Sortable do
22- @moduledoc """
33- Codec for the base32-sortable encoding.
44- """
55-66- @alphabet ~c(234567abcdefghijklmnopqrstuvwxyz)
77- @alphabet_len length(@alphabet)
88-99- @doc """
1010- Encode an integer as a base32-sortable string.
1111- """
1212- @spec encode(integer()) :: String.t()
1313- def encode(int) when is_integer(int), do: do_encode(int, "")
1414-1515- @spec do_encode(integer(), String.t()) :: String.t()
1616- defp do_encode(0, acc), do: acc
1717-1818- defp do_encode(int, acc) do
1919- char_index = rem(int, @alphabet_len)
2020- new_int = div(int, @alphabet_len)
2121-2222- # Chars are prepended to the accumulator because rem/div is pulling them off the tail of the integer.
2323- do_encode(new_int, <<Enum.at(@alphabet, char_index)>> <> acc)
2424- end
2525-2626- @doc """
2727- Decode a base32-sortable string to an integer.
2828- """
2929- @spec decode(String.t()) :: integer()
3030- def decode(str) when is_binary(str), do: do_decode(str, 0)
3131-3232- @spec do_decode(String.t(), integer()) :: integer()
3333- defp do_decode(<<>>, acc), do: acc
3434-3535- defp do_decode(<<char::utf8, rest::binary>>, acc) do
3636- i = Enum.find_index(@alphabet, fn x -> x == char end)
3737- do_decode(rest, acc * @alphabet_len + i)
3838- end
3939-end
+94
lib/mix/tasks/atex.lexicons.ex
···11+defmodule Mix.Tasks.Atex.Lexicons do
22+ @moduledoc """
33+ Generate Elixir modules from AT Protocol lexicons, which can then be used to
44+ validate data at runtime.
55+66+ AT Protocol lexicons are JSON files that define parts of the AT Protocol data
77+ model. This task processes these lexicon files and generates corresponding
88+ Elixir modules.
99+1010+ ## Usage
1111+1212+ mix atex.lexicons [OPTIONS] [PATHS]
1313+1414+ ## Arguments
1515+1616+ - `PATHS` - List of lexicon files to process. Also supports standard glob
1717+ syntax for reading many lexicons at once.
1818+1919+ ## Options
2020+2121+ - `-o`/`--output` - Output directory for generated modules (default:
2222+ `lib/atproto`)
2323+2424+ ## Examples
2525+2626+ Process all JSON files in the lexicons directory:
2727+2828+ mix atex.lexicons lexicons/**/*.json
2929+3030+ Process specific lexicon files:
3131+3232+ mix atex.lexicons lexicons/com/atproto/repo/*.json lexicons/app/bsky/actor/profile.json
3333+3434+ Generate modules to a custom output directory:
3535+3636+ mix atex.lexicons lexicons/**/*.json --output lib/my_atproto
3737+ """
3838+ @shortdoc "Generate Elixir modules from AT Protocol lexicons."
3939+4040+ use Mix.Task
4141+ require EEx
4242+4343+ @switches [output: :string]
4444+ @aliases [o: :output]
4545+ @template_path Path.expand("../../../priv/templates/lexicon.eex", __DIR__)
4646+4747+ @impl true
4848+ def run(args) do
4949+ {options, globs} = OptionParser.parse!(args, switches: @switches, aliases: @aliases)
5050+5151+ output = Keyword.get(options, :output, "lib/atproto")
5252+ paths = Enum.flat_map(globs, &Path.wildcard/1)
5353+5454+ if length(paths) == 0 do
5555+ Mix.shell().error("No valid search paths have been provided, aborting.")
5656+ else
5757+ Mix.shell().info("Generating modules for lexicons into #{output}")
5858+5959+ Enum.each(paths, fn path ->
6060+ Mix.shell().info("- #{path}")
6161+ generate(path, output)
6262+ end)
6363+ end
6464+ end
6565+6666+ # TODO: validate schema?
6767+ defp generate(input, output) do
6868+ lexicon =
6969+ input
7070+ |> File.read!()
7171+ |> JSON.decode!()
7272+7373+ if not is_binary(lexicon["id"]) do
7474+ raise ArgumentError, message: "Malformed lexicon: does not have an `id` field."
7575+ end
7676+7777+ code = lexicon |> template() |> Code.format_string!() |> Enum.join("")
7878+7979+ file_path =
8080+ lexicon["id"]
8181+ |> String.split(".")
8282+ |> Enum.join("/")
8383+ |> then(&(&1 <> ".ex"))
8484+ |> then(&Path.join(output, &1))
8585+8686+ file_path
8787+ |> Path.dirname()
8888+ |> File.mkdir_p!()
8989+9090+ File.write!(file_path, code)
9191+ end
9292+9393+ EEx.function_from_file(:defp, :template, @template_path, [:lexicon])
9494+end
-169
lib/tid.ex
···11-defmodule Atex.TID do
22- @moduledoc """
33- Struct and helper functions for dealing with AT Protocol TIDs (Timestamp
44- Identifiers), a 13-character string representation of a 64-bit number
55- comprised of a Unix timestamp (in microsecond precision) and a random "clock
66- identifier" to help avoid collisions.
77-88- ATProto spec: https://atproto.com/specs/tid
99-1010- TID strings are always 13 characters long. All bits in the 64-bit number are
1111- encoded, essentially meaning that the string is padded with "2" if necessary,
1212- (the 0th character in the base32-sortable alphabet).
1313- """
1414- import Bitwise
1515- alias Atex.Base32Sortable
1616- use TypedStruct
1717-1818- @re ~r/^[234567abcdefghij][234567abcdefghijklmnopqrstuvwxyz]{12}$/
1919-2020- @typedoc """
2121- A Unix timestamp representing when the TID was created.
2222- """
2323- @type timestamp() :: integer()
2424-2525- @typedoc """
2626- An integer to be used for the lower 10 bits of the TID.
2727- """
2828- @type clock_id() :: 0..1023
2929-3030- typedstruct enforce: true do
3131- field :timestamp, timestamp()
3232- field :clock_id, clock_id()
3333- end
3434-3535- @doc """
3636- Returns a TID for the current moment in time, along with a random clock ID.
3737- """
3838- @spec now() :: t()
3939- def now,
4040- do: %__MODULE__{
4141- timestamp: DateTime.utc_now(:microsecond) |> DateTime.to_unix(:microsecond),
4242- clock_id: gen_clock_id()
4343- }
4444-4545- @doc """
4646- Create a new TID from a `DateTime` or an integer representing a Unix timestamp in microseconds.
4747-4848- If `clock_id` isn't provided, a random one will be generated.
4949- """
5050- @spec new(DateTime.t() | integer(), integer() | nil) :: t()
5151- def new(source, clock_id \\ nil)
5252-5353- def new(%DateTime{} = datetime, clock_id),
5454- do: %__MODULE__{
5555- timestamp: DateTime.to_unix(datetime, :microsecond),
5656- clock_id: clock_id || gen_clock_id()
5757- }
5858-5959- def new(unix, clock_id) when is_integer(unix),
6060- do: %__MODULE__{timestamp: unix, clock_id: clock_id || gen_clock_id()}
6161-6262- @doc """
6363- Convert a TID struct to an instance of `DateTime`.
6464- """
6565- def to_datetime(%__MODULE__{} = tid), do: DateTime.from_unix(tid.timestamp, :microsecond)
6666-6767- @doc """
6868- Generate a random integer to be used as a `clock_id`.
6969- """
7070- @spec gen_clock_id() :: clock_id()
7171- def gen_clock_id, do: :rand.uniform(1024) - 1
7272-7373- @doc """
7474- Decode a TID string into an `Atex.TID` struct, returning an error if it's invalid.
7575-7676- ## Examples
7777-7878- Syntactically valid TIDs:
7979-8080- iex> Atex.TID.decode("3jzfcijpj2z2a")
8181- {:ok, %Atex.TID{clock_id: 6, timestamp: 1688137381887007}}
8282-8383- iex> Atex.TID.decode("7777777777777")
8484- {:ok, %Atex.TID{clock_id: 165, timestamp: 5811096293381285}}
8585-8686- iex> Atex.TID.decode("3zzzzzzzzzzzz")
8787- {:ok, %Atex.TID{clock_id: 1023, timestamp: 2251799813685247}}
8888-8989- iex> Atex.TID.decode("2222222222222")
9090- {:ok, %Atex.TID{clock_id: 0, timestamp: 0}}
9191-9292- Invalid TIDs:
9393-9494- # not base32
9595- iex> Atex.TID.decode("3jzfcijpj2z21")
9696- :error
9797- iex> Atex.TID.decode("0000000000000")
9898- :error
9999-100100- # case-sensitive
101101- iex> Atex.TID.decode("3JZFCIJPJ2Z2A")
102102- :error
103103-104104- # too long/short
105105- iex> Atex.TID.decode("3jzfcijpj2z2aa")
106106- :error
107107- iex> Atex.TID.decode("3jzfcijpj2z2")
108108- :error
109109- iex> Atex.TID.decode("222")
110110- :error
111111-112112- # legacy dash syntax *not* supported (TTTT-TTT-TTTT-CC)
113113- iex> Atex.TID.decode("3jzf-cij-pj2z-2a")
114114- :error
115115-116116- # high bit can't be set
117117- iex> Atex.TID.decode("zzzzzzzzzzzzz")
118118- :error
119119- iex> Atex.TID.decode("kjzfcijpj2z2a")
120120- :error
121121-122122- """
123123- @spec decode(String.t()) :: {:ok, t()} | :error
124124- def decode(<<timestamp::binary-size(11), clock_id::binary-size(2)>> = tid) do
125125- if Regex.match?(@re, tid) do
126126- timestamp = Base32Sortable.decode(timestamp)
127127- clock_id = Base32Sortable.decode(clock_id)
128128-129129- {:ok,
130130- %__MODULE__{
131131- timestamp: timestamp,
132132- clock_id: clock_id
133133- }}
134134- else
135135- :error
136136- end
137137- end
138138-139139- def decode(_tid), do: :error
140140-141141- @doc """
142142- Encode a TID struct into a string.
143143-144144- ## Examples
145145-146146- iex> Atex.TID.encode(%Atex.TID{clock_id: 6, timestamp: 1688137381887007})
147147- "3jzfcijpj2z2a"
148148-149149- iex> Atex.TID.encode(%Atex.TID{clock_id: 165, timestamp: 5811096293381285})
150150- "7777777777777"
151151-152152- iex> Atex.TID.encode(%Atex.TID{clock_id: 1023, timestamp: 2251799813685247})
153153- "3zzzzzzzzzzzz"
154154-155155- iex> Atex.TID.encode(%Atex.TID{clock_id: 0, timestamp: 0})
156156- "2222222222222"
157157-158158- """
159159- @spec encode(t()) :: String.t()
160160- def encode(%__MODULE__{} = tid) do
161161- timestamp = tid.timestamp |> Base32Sortable.encode() |> String.pad_leading(11, "2")
162162- clock_id = (tid.clock_id &&& 1023) |> Base32Sortable.encode() |> String.pad_leading(2, "2")
163163- timestamp <> clock_id
164164- end
165165-end
166166-167167-defimpl String.Chars, for: Atex.TID do
168168- def to_string(tid), do: Atex.TID.encode(tid)
169169-end
-27
lib/xrpc/adapter/req.ex
···11-defmodule Atex.XRPC.Adapter.Req do
22- @moduledoc """
33- `Req` adapter for XRPC.
44- """
55-66- @behaviour Atex.XRPC.Adapter
77-88- def get(url, opts) do
99- Req.get(url, opts) |> adapt()
1010- end
1111-1212- def post(url, opts) do
1313- Req.post(url, opts) |> adapt()
1414- end
1515-1616- defp adapt({:ok, %Req.Response{status: 200} = res}) do
1717- {:ok, res.body}
1818- end
1919-2020- defp adapt({:ok, %Req.Response{} = res}) do
2121- {:error, res.status, res.body}
2222- end
2323-2424- defp adapt({:error, exception}) do
2525- {:error, exception}
2626- end
2727-end
-12
lib/xrpc/adapter.ex
···11-defmodule Atex.XRPC.Adapter do
22- @moduledoc """
33- Behaviour for defining a HTTP client adapter to be used for XRPC.
44- """
55-66- @type success() :: {:ok, map()}
77- @type error() :: {:error, integer(), map()} | {:error, term()}
88- @type result() :: success() | error()
99-1010- @callback get(url :: String.t(), opts :: keyword()) :: result()
1111- @callback post(url :: String.t(), opts :: keyword()) :: result()
1212-end
-87
lib/xrpc/client.ex
···11-defmodule Atex.XRPC.Client do
22- @doc """
33- Struct to store client information for ATProto XRPC.
44- """
55-66- alias Atex.XRPC
77- use TypedStruct
88-99- typedstruct do
1010- field :endpoint, String.t(), enforce: true
1111- field :access_token, String.t() | nil
1212- field :refresh_token, String.t() | nil
1313- end
1414-1515- @doc """
1616- Create a new `Atex.XRPC.Client` from an endpoint, and optionally an
1717- access/refresh token.
1818-1919- Endpoint should be the base URL of a PDS, or an AppView in the case of
2020- unauthenticated requests (like Bluesky's public API), e.g.
2121- `https://bsky.social`.
2222- """
2323- @spec new(String.t()) :: t()
2424- @spec new(String.t(), String.t() | nil) :: t()
2525- @spec new(String.t(), String.t() | nil, String.t() | nil) :: t()
2626- def new(endpoint, access_token \\ nil, refresh_token \\ nil) do
2727- %__MODULE__{endpoint: endpoint, access_token: access_token, refresh_token: refresh_token}
2828- end
2929-3030- @doc """
3131- Create a new `Atex.XRPC.Client` by logging in with an `identifier` and
3232- `password` to fetch an initial pair of access & refresh tokens.
3333-3434- Uses `com.atproto.server.createSession` under the hood, so `identifier` can be
3535- either a handle or a DID.
3636-3737- ## Examples
3838-3939- iex> Atex.XRPC.Client.login("https://bsky.social", "example.com", "password123")
4040- {:ok, %Atex.XRPC.Client{...}}
4141- """
4242- @spec login(String.t(), String.t(), String.t()) :: {:ok, t()} | XRPC.Adapter.error()
4343- @spec login(String.t(), String.t(), String.t(), String.t() | nil) ::
4444- {:ok, t()} | XRPC.Adapter.error()
4545- def login(endpoint, identifier, password, auth_factor_token \\ nil) do
4646- json =
4747- %{identifier: identifier, password: password}
4848- |> then(
4949- &if auth_factor_token do
5050- Map.merge(&1, %{authFactorToken: auth_factor_token})
5151- else
5252- &1
5353- end
5454- )
5555-5656- response = XRPC.unauthed_post(endpoint, "com.atproto.server.createSession", json: json)
5757-5858- case response do
5959- {:ok, %{"accessJwt" => access_token, "refreshJwt" => refresh_token}} ->
6060- {:ok, new(endpoint, access_token, refresh_token)}
6161-6262- err ->
6363- err
6464- end
6565- end
6666-6767- @doc """
6868- Request a new `refresh_token` for the given client.
6969- """
7070- @spec refresh(t()) :: {:ok, t()} | XRPC.Adapter.error()
7171- def refresh(%__MODULE__{endpoint: endpoint, refresh_token: refresh_token} = client) do
7272- response =
7373- XRPC.unauthed_post(
7474- endpoint,
7575- "com.atproto.server.refreshSession",
7676- XRPC.put_auth([], refresh_token)
7777- )
7878-7979- case response do
8080- {:ok, %{"accessJwt" => access_token, "refreshJwt" => refresh_token}} ->
8181- %{client | access_token: access_token, refresh_token: refresh_token}
8282-8383- err ->
8484- err
8585- end
8686- end
8787-end
-71
lib/xrpc.ex
···11-defmodule Atex.XRPC do
22- alias Atex.XRPC
33-44- defp adapter do
55- Application.get_env(:atex, :adapter, XRPC.Adapter.Req)
66- end
77-88- # TODO: automatic user-agent, and env for changing it
99-1010- # TODO: consistent struct shape/protocol for Lexicon schemas so that user can pass in
1111- # an object (hopefully validated by its module) without needing to specify the
1212- # name & opts separately, and possibly verify the output response against it?
1313-1414- # TODO: auto refresh, will need to return a client instance in each method.
1515-1616- @doc """
1717- Perform a HTTP GET on a XRPC resource. Called a "query" in lexicons.
1818- """
1919- @spec get(XRPC.Client.t(), String.t(), keyword()) :: XRPC.Adapter.result()
2020- def get(%XRPC.Client{} = client, name, opts \\ []) do
2121- opts = put_auth(opts, client.access_token)
2222- adapter().get(url(client, name), opts)
2323- end
2424-2525- @doc """
2626- Perform a HTTP POST on a XRPC resource. Called a "prodecure" in lexicons.
2727- """
2828- @spec post(XRPC.Client.t(), String.t(), keyword()) :: XRPC.Adapter.result()
2929- def post(%XRPC.Client{} = client, name, opts \\ []) do
3030- # TODO: look through available HTTP clients and see if they have a
3131- # consistent way of providing JSON bodies with auto content-type. If not,
3232- # create one for adapters.
3333- opts = put_auth(opts, client.access_token)
3434- adapter().post(url(client, name), opts)
3535- end
3636-3737- @doc """
3838- Like `get/3` but is unauthenticated by default.
3939- """
4040- @spec unauthed_get(String.t(), String.t(), keyword()) :: XRPC.Adapter.result()
4141- def unauthed_get(endpoint, name, opts \\ []) do
4242- adapter().get(url(endpoint, name), opts)
4343- end
4444-4545- @doc """
4646- Like `post/3` but is unauthenticated by default.
4747- """
4848- @spec unauthed_post(String.t(), String.t(), keyword()) :: XRPC.Adapter.result()
4949- def unauthed_post(endpoint, name, opts \\ []) do
5050- adapter().post(url(endpoint, name), opts)
5151- end
5252-5353- # TODO: use URI module for joining instead?
5454- @spec url(XRPC.Client.t() | String.t(), String.t()) :: String.t()
5555- defp url(%XRPC.Client{endpoint: endpoint}, name), do: url(endpoint, name)
5656- defp url(endpoint, name) when is_binary(endpoint), do: "#{endpoint}/xrpc/#{name}"
5757-5858- @doc """
5959- Put an `authorization` header into a keyword list of options to pass to a HTTP client.
6060- """
6161- @spec put_auth(keyword(), String.t()) :: keyword()
6262- def put_auth(opts, token),
6363- do: put_headers(opts, authorization: "Bearer #{token}")
6464-6565- @spec put_headers(keyword(), keyword()) :: keyword()
6666- defp put_headers(opts, headers) do
6767- opts
6868- |> Keyword.put_new(:headers, [])
6969- |> Keyword.update(:headers, [], &Keyword.merge(&1, headers))
7070- end
7171-end
···11+defmodule Atex.OAuth.PermissionTest do
22+ use ExUnit.Case, async: true
33+ alias Atex.OAuth.Permission
44+ doctest Permission
55+66+ describe "account/1" do
77+ test "requires `:attr`" do
88+ assert_raise ArgumentError, ~r/`:attr` must be provided/, fn ->
99+ Permission.account()
1010+ end
1111+ end
1212+1313+ test "requires valid `:attr`" do
1414+ assert_raise ArgumentError, ~r/`:attr` must be `:email` or `:repo`/, fn ->
1515+ Permission.account(attr: :foobar)
1616+ end
1717+1818+ assert Permission.account(attr: :email)
1919+ end
2020+2121+ test "requires valid `:action`" do
2222+ assert_raise ArgumentError, ~r/`:action` must be `:read`, `:manage`, or `nil`/, fn ->
2323+ Permission.account(attr: :email, action: :foobar)
2424+ end
2525+2626+ assert Permission.account(attr: :email, action: :manage)
2727+ assert Permission.account(attr: :repo, action: nil)
2828+ end
2929+ end
3030+3131+ describe "rpc/2" do
3232+ test "requires at least `:aud` or `:inherit_aud`" do
3333+ assert_raise ArgumentError, ~r/must specify either/, fn ->
3434+ Permission.rpc("com.example.getProfile")
3535+ end
3636+ end
3737+3838+ test "disallows `:aud` and `:inherit_aud` at the same time" do
3939+ assert_raise ArgumentError, ~r/cannot specify both/, fn ->
4040+ Permission.rpc("com.example.getProfile", aud: "example", inherit_aud: true)
4141+ end
4242+ end
4343+4444+ test "disallows wildcard for `lxm` and `aud` at the same time" do
4545+ assert_raise ArgumentError, ~r/wildcard `lxm` and wildcard `aud`/, fn ->
4646+ Permission.rpc("*", aud: "*")
4747+ end
4848+ end
4949+ end
5050+end