···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
2612.direnv
2713.vscode/
2814.elixir_ls
2915lexicons
3030-lib/atproto
3116secrets
3232-node_modules
3333-atproto-oauth-example
3434-.DS_Store1717+.DS_Store
1818+CLAUDE.md
1919+tmp
2020+temp
+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
+62-1
CHANGELOG.md
···8899<!-- ## [Unreleased] -->
10101111+## [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+1170## [0.5.0] - 2025-10-11
12711372### Breaking Changes
···6512466125Initial release.
671266868-[unreleased]: https://github.com/cometsh/atex/compare/v0.5.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
69130[0.5.0]: https://github.com/cometsh/atex/releases/tag/v0.5.0
70131[0.4.0]: https://github.com/cometsh/atex/releases/tag/v0.4.0
71132[0.3.0]: https://github.com/cometsh/atex/releases/tag/v0.3.0
+4-2
README.md
···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-- [ ] Extended XRPC client with support for validated inputs/outputs
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.3"}
2626+ {:atex, "~> 0.7"}
2527 ]
2628end
2729```
···44 use Application
5566 def start(_type, _args) do
77- children = [Atex.IdentityResolver.Cache]
77+ children = [
88+ Atex.IdentityResolver.Cache,
99+ Atex.OAuth.Cache,
1010+ Atex.OAuth.SessionStore,
1111+ {Mutex, name: Atex.SessionMutex}
1212+ ]
1313+814 Supervisor.start_link(children, strategy: :one_for_one)
915 end
1016end
+35-22
lib/atex/identity_resolver/cache/ets.ex
···11defmodule 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+29 alias Atex.IdentityResolver.Identity
310 @behaviour Atex.IdentityResolver.Cache
411 use Supervisor
51266- @table :atex_identities
1313+ @cache :atex_identities_cache
1414+ @ttl_ms :timer.hours(1)
715816 def start_link(opts) do
99- Supervisor.start_link(__MODULE__, opts)
1717+ Supervisor.start_link(__MODULE__, opts, name: __MODULE__)
1018 end
11191220 @impl Supervisor
1321 def init(_opts) do
1414- :ets.new(@table, [:set, :public, :named_table])
1515- Supervisor.init([], strategy: :one_for_one)
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)
1632 end
17331834 @impl Atex.IdentityResolver.Cache
1935 @spec insert(Identity.t()) :: Identity.t()
2036 def insert(identity) do
2121- # TODO: benchmark lookups vs match performance, is it better to use a "composite" key or two inserts?
2222- :ets.insert(@table, {{identity.did, identity.handle}, identity})
3737+ ConCache.put(@cache, {:did, identity.did}, identity)
3838+ ConCache.put(@cache, {:handle, identity.handle}, identity)
2339 identity
2440 end
25412642 @impl Atex.IdentityResolver.Cache
2743 @spec get(String.t()) :: {:ok, Identity.t()} | {:error, atom()}
2844 def get(identifier) do
2929- lookup(identifier)
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
3055 end
31563257 @impl Atex.IdentityResolver.Cache
3358 @spec delete(String.t()) :: :noop | Identity.t()
3459 def delete(identifier) do
3535- case lookup(identifier) do
6060+ case get(identifier) do
3661 {:ok, identity} ->
3737- :ets.delete(@table, {identity.did, identity.handle})
6262+ ConCache.delete(@cache, {:did, identity.did})
6363+ ConCache.delete(@cache, {:handle, identity.handle})
3864 identity
39654066 _ ->
4167 :noop
4242- end
4343- end
4444-4545- defp lookup(identifier) do
4646- case :ets.match(@table, {{identifier, :_}, :"$1"}) do
4747- [] ->
4848- case :ets.match(@table, {{:_, identifier}, :"$1"}) do
4949- [] -> {:error, :not_found}
5050- [[identity]] -> {:ok, identity}
5151- end
5252-5353- [[identity]] ->
5454- {:ok, identity}
5568 end
5669 end
5770end
+2-2
lib/atex/lexicon/validators/array.ex
···44 @option_keys [:min_length, :max_length]
5566 # Needs type input
77- @spec validate(Peri.schema_def(), term(), list(option())) :: Peri.validation_result()
88- def validate(inner_type, value, options) when is_list(value) do
77+ @spec validate(term(), Peri.schema_def(), list(option())) :: Peri.validation_result()
88+ def validate(value, inner_type, options) when is_list(value) do
99 # TODO: validate inner_type with Peri to make sure it's correct?
10101111 options
+13-44
lib/atex/lexicon/validators/string.ex
···11defmodule Atex.Lexicon.Validators.String do
22 alias Atex.Lexicon.Validators
3344- @type format() ::
55- :at_identifier
66- | :at_uri
77- | :cid
88- | :datetime
99- | :did
1010- | :handle
1111- | :nsid
1212- | :tid
1313- | :record_key
1414- | :uri
1515- | :language
1616-174 @type option() ::
1818- {:format, format()}
55+ {:format, String.t()}
196 | {:min_length, non_neg_integer()}
207 | {:max_length, non_neg_integer()}
218 | {:min_graphemes, non_neg_integer()}
···31183219 @record_key_re ~r"^[a-zA-Z0-9.-_:~]$"
33203434- # TODO: probably should go into a different module, one with general lexicon -> validator gen conversions
3535- @spec format_to_atom(String.t()) :: format()
3636- def format_to_atom(format) do
3737- case format do
3838- "at-identifier" -> :at_identifier
3939- "at-uri" -> :at_uri
4040- "cid" -> :cid
4141- "datetime" -> :datetime
4242- "did" -> :did
4343- "handle" -> :handle
4444- "nsid" -> :nsid
4545- "tid" -> :tid
4646- "record-key" -> :record_key
4747- "uri" -> :uri
4848- "language" -> :language
4949- _ -> raise "Unknown lexicon string format `#{format}`"
5050- end
5151- end
5252-5321 @spec validate(term(), list(option())) :: Peri.validation_result()
5422 def validate(value, options) when is_binary(value) do
5523 options
···74427543 defp validate_option(_value, {option, nil}) when option in @option_keys, do: :ok
76447777- defp validate_option(value, {:format, :at_identifier}),
4545+ defp validate_option(value, {:format, "at-identifier"}),
7846 do:
7947 Validators.boolean_validate(
8048 Atex.DID.match?(value) or Atex.Handle.match?(value),
8149 "should be a valid DID or handle"
8250 )
83518484- defp validate_option(value, {:format, :at_uri}),
5252+ defp validate_option(value, {:format, "at-uri"}),
8553 do: Validators.boolean_validate(Atex.AtURI.match?(value), "should be a valid at:// URI")
86548787- defp validate_option(value, {:format, :cid}) do
5555+ defp validate_option(value, {:format, "cid"}) do
8856 # TODO: is there a regex provided by the lexicon docs/somewhere?
8957 try do
9058 Multiformats.CID.decode(value)
5959+ :ok
9160 rescue
9261 _ -> {:error, "should be a valid CID", []}
9362 end
9463 end
95649696- defp validate_option(value, {:format, :datetime}) do
6565+ defp validate_option(value, {:format, "datetime"}) do
9766 # NaiveDateTime is used over DateTime because the result isn't actually
9867 # being used, so we don't need to include a calendar library just for this.
9968 case NaiveDateTime.from_iso8601(value) do
···10271 end
10372 end
10473105105- defp validate_option(value, {:format, :did}),
7474+ defp validate_option(value, {:format, "did"}),
10675 do: Validators.boolean_validate(Atex.DID.match?(value), "should be a valid DID")
10776108108- defp validate_option(value, {:format, :handle}),
7777+ defp validate_option(value, {:format, "handle"}),
10978 do: Validators.boolean_validate(Atex.Handle.match?(value), "should be a valid handle")
11079111111- defp validate_option(value, {:format, :nsid}),
8080+ defp validate_option(value, {:format, "nsid"}),
11281 do: Validators.boolean_validate(Atex.NSID.match?(value), "should be a valid NSID")
11382114114- defp validate_option(value, {:format, :tid}),
8383+ defp validate_option(value, {:format, "tid"}),
11584 do: Validators.boolean_validate(Atex.TID.match?(value), "should be a valid TID")
11685117117- defp validate_option(value, {:format, :record_key}),
8686+ defp validate_option(value, {:format, "record-key"}),
11887 do:
11988 Validators.boolean_validate(
12089 Regex.match?(@record_key_re, value),
12190 "should be a valid record key"
12291 )
12392124124- defp validate_option(value, {:format, :uri}) do
9393+ defp validate_option(value, {:format, "uri"}) do
12594 case URI.new(value) do
12695 {:ok, _} -> :ok
12796 {:error, _} -> {:error, "should be a valid URI", []}
12897 end
12998 end
13099131131- defp validate_option(value, {:format, :language}) do
100100+ defp validate_option(value, {:format, "language"}) do
132101 case Cldr.LanguageTag.parse(value) do
133102 {:ok, _} -> :ok
134103 {:error, _} -> {:error, "should be a valid BCP 47 language tag", []}
+5
lib/atex/lexicon/validators.ex
···8181 }
8282 end
83838484+ @spec lazy_ref(module(), atom()) :: Peri.schema()
8585+ def lazy_ref(module, schema_name) do
8686+ {:custom, {module, schema_name, []}}
8787+ end
8888+8489 @spec boolean_validate(boolean(), String.t(), keyword() | map()) ::
8590 Peri.validation_result()
8691 def boolean_validate(success?, error_message, context \\ []) do
+307-59
lib/atex/lexicon.ex
···11defmodule Atex.Lexicon do
22- @moduledoc """
33- Provide `deflexicon` macro for defining a module with types and schemas from an entire lexicon definition.
44-55- Should it also define structs, with functions to convert from input case to snake case?
66- """
77-82 alias Atex.Lexicon.Validators
93104 defmacro __using__(_opts) do
···159 end
1610 end
17111212+ @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+ """
1879 defmacro deflexicon(lexicon) do
1980 # Better way to get the real map, without having to eval? (custom function to compose one from quoted?)
2081 lexicon =
···2788 defs =
2889 lexicon.defs
2990 |> Enum.flat_map(fn {def_name, def} -> def_to_schema(lexicon.id, def_name, def) end)
3030- |> Enum.map(fn {schema_key, quoted_schema, quoted_type} ->
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} ->
3196 identity_type =
3232- if schema_key === :main do
9797+ if schema_key == :main do
3398 quote do
3499 @type t() :: unquote(quoted_type)
35100 end
36101 end
37102103103+ 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+38119 quote do
3939- @type unquote(schema_key)() :: unquote(quoted_type)
120120+ @type unquote(Recase.to_snake(schema_key))() :: unquote(quoted_type)
40121 unquote(identity_type)
411224242- defschema unquote(schema_key), unquote(quoted_schema)
123123+ defschema unquote(Recase.to_snake(schema_key)), unquote(quoted_schema)
124124+125125+ unquote(struct_def)
43126 end
44127 end)
4512846129 quote do
4747- def id, do: unquote(Atex.NSID.to_atom(lexicon.id))
130130+ def id, do: unquote(lexicon.id)
4813149132 unquote_splicing(defs)
50133 end
51134 end
135135+136136+ # - [ ] `t()` type should be the struct in it. (add to non-main structs too?)
5213753138 @spec def_to_schema(nsid :: String.t(), def_name :: String.t(), lexicon_def :: map()) ::
5454- list({key :: atom(), quoted_schema :: term(), quoted_type :: term()})
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+ )
5515256153 defp def_to_schema(nsid, def_name, %{type: "record", record: record}) do
57154 # 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+58164 def_to_schema(nsid, def_name, record)
59165 end
601666161- # TODO: need to spit out an extra 'branded' type with `$type` field, for use in union refs.
167167+ # TODO: add struct to types
62168 defp def_to_schema(
63169 nsid,
64170 def_name,
···70176 required = Map.get(def, :required, [])
71177 nullable = Map.get(def, :nullable, [])
721787373- properties
7474- |> Enum.map(fn {key, field} ->
7575- {quoted_schema, quoted_type} = field_to_schema(field, nsid)
7676- is_nullable = key in nullable
7777- is_required = key in required
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})
781947979- quoted_schema =
8080- quoted_schema
8181- |> then(
8282- &if is_nullable, do: quote(do: {:either, {{:literal, nil}, unquote(&1)}}), else: &1
8383- )
8484- |> then(&if is_required, do: quote(do: {:required, unquote(&1)}), else: &1)
8585- |> then(&{key, &1})
195195+ key_type = if is_required, do: :required, else: :optional
861968787- key_type = if is_required, do: :required, else: :optional
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})
882078989- quoted_type =
9090- quoted_type
9191- |> then(
9292- &if is_nullable do
9393- {:|, [], [&1, nil]}
9494- else
9595- &1
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
96246 end
9797- )
9898- |> then(&{{key_type, [], [key]}, &1})
247247+ end
99248100100- {quoted_schema, quoted_type}
101101- end)
102102- |> Enum.reduce({[], []}, fn {quoted_schema, quoted_type}, {schemas, types} ->
103103- {[quoted_schema | schemas], [quoted_type | types]}
104104- end)
105105- |> then(fn {quoted_schemas, quoted_types} ->
106106- [{atomise(def_name), {:%{}, [], quoted_schemas}, {:%{}, [], quoted_types}}]
107107- end)
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}]
108275 end
109276110277 # TODO: validating errors?
···127294 schema
128295 end
129296130130- [params, output]
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]
131325 |> Enum.reject(&is_nil/1)
132326 end
133327···157351 schema
158352 end
159353160160- [params, output, input]
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]
161404 |> Enum.reject(&is_nil/1)
162405 end
163406···231474 :minGraphemes
232475 ])
233476 |> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end)
234234- |> then(&{:custom, {Validators.String, :validate, [&1]}})
477477+ |> Validators.string()
235478 |> maybe_default(field)
236479 end
237480 |> then(
···262505 field
263506 |> Map.take([:maximum, :minimum])
264507 |> Keyword.new()
265265- |> then(&{:custom, {Validators.Integer, [&1]}})
508508+ |> Validators.integer()
266509 |> maybe_default(field)
267510 end
268511 |> then(
···284527 |> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end)
285528 |> then(&Validators.array(inner_schema, &1))
286529 |> then(&Macro.escape/1)
530530+ # TODO: we should be able to unquote this now...
287531 # Can't unquote the inner_schema beforehand as that would risk evaluating `get_schema`s which don't exist yet.
288532 # There's probably a better way to do this lol.
289533 |> then(fn {:custom, {:{}, c, [Validators.Array, :validate, [quoted_inner_schema | args]]}} ->
···341585 |> Atex.NSID.expand_possible_fragment_shorthand(ref)
342586 |> Atex.NSID.to_atom_with_fragment()
343587344344- {quote do
345345- unquote(nsid).get_schema(unquote(fragment))
346346- end,
347347- quote do
348348- unquote(nsid).unquote(fragment)()
349349- end}
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+ }
350596 end
351597352598 defp field_to_schema(%{type: "union", refs: refs}, nsid) do
···362608 |> Atex.NSID.expand_possible_fragment_shorthand(ref)
363609 |> Atex.NSID.to_atom_with_fragment()
364610365365- {quote do
366366- unquote(nsid).get_schema(unquote(fragment))
367367- end,
368368- quote do
369369- unquote(nsid).unquote(fragment)()
370370- end}
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+ }
371619 end)
372620 |> Enum.reduce({[], []}, fn {quoted_schema, quoted_type}, {schemas, types} ->
373621 {[quoted_schema | schemas], [quoted_type | types]}
+9
lib/atex/nsid.ex
···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
4857end
+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
+149-70
lib/atex/oauth/plug.ex
···4455 This module provides three endpoints:
6677- - `GET /login?handle=<handle>` - Initiates the OAuth authorization flow for
88- a given handle
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···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".
17171818+ 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+1829 ## Example
19302031 Example implementation showing how to set up the OAuth plug with proper
2121- session handling:
3232+ session handling, error handling, and a callback function.
22332334 defmodule ExampleOAuthPlug do
2435 use Plug.Router
3636+ use Plug.ErrorHandler
25372638 plug :put_secret_key_base
2739···3345 plug :match
3446 plug :dispatch
35473636- forward "/oauth", to: Atex.OAuth.Plug
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
37573858 def put_secret_key_base(conn, _) do
3959 put_in(
···4161 "very long key base with at least 64 bytes"
4262 )
4363 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
4482 end
45834684 ## Session Storage
···5997 alias Atex.{IdentityResolver, IdentityResolver.DIDDocument}
60986199 @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
6211763118 plug :match
64119 plug :dispatch
···68123 handle = conn.query_params["handle"]
6912470125 if !handle do
7171- send_resp(conn, 400, "Need `handle` query parameter")
7272- else
7373- case IdentityResolver.resolve(handle) do
7474- {:ok, identity} ->
7575- pds = DIDDocument.get_pds_endpoint(identity.document)
7676- {:ok, authz_server} = OAuth.get_authorization_server(pds)
7777- {:ok, authz_metadata} = OAuth.get_authorization_server_metadata(authz_server)
7878- state = OAuth.create_nonce()
7979- code_verifier = OAuth.create_nonce()
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()
801388181- case OAuth.create_authorization_url(
8282- authz_metadata,
8383- state,
8484- code_verifier,
8585- handle
8686- ) do
8787- {:ok, authz_url} ->
8888- conn
8989- |> put_resp_cookie("state", state, @oauth_cookie_opts)
9090- |> put_resp_cookie("code_verifier", code_verifier, @oauth_cookie_opts)
9191- |> put_resp_cookie("issuer", authz_metadata.issuer, @oauth_cookie_opts)
9292- |> put_resp_header("location", authz_url)
9393- |> send_resp(307, "")
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, "")
941529595- err ->
9696- Logger.error("failed to reate authorization url, #{inspect(err)}")
9797- send_resp(conn, 500, "Internal server error")
9898- end
153153+ {:error, _err} ->
154154+ raise Atex.OAuth.Error,
155155+ message: "Failed to create authorization URL",
156156+ reason: :authorization_url_failed
157157+ end
99158100100- {:error, err} ->
101101- Logger.error("Failed to resolve handle, #{inspect(err)}")
102102- send_resp(conn, 400, "Invalid handle")
103103- end
159159+ _err ->
160160+ raise Atex.OAuth.Error, message: "Invalid or unresolvable handle", reason: :invalid_handle
104161 end
105162 end
106163···112169113170 get "/callback" do
114171 conn = conn |> fetch_query_params() |> fetch_session()
172172+ callback = Keyword.get(conn.private.atex_oauth_opts, :callback)
115173 cookies = get_cookies(conn)
116174 stored_state = cookies["state"]
117175 stored_code_verifier = cookies["code_verifier"]
···122180123181 if !stored_state || !stored_code_verifier || !stored_issuer || (!code || !state) ||
124182 stored_state != state do
125125- send_resp(conn, 400, "Invalid request")
126126- else
127127- with {:ok, authz_metadata} <- OAuth.get_authorization_server_metadata(stored_issuer),
128128- dpop_key <- JOSE.JWK.generate_key({:ec, "P-256"}),
129129- {:ok, tokens, nonce} <-
130130- OAuth.validate_authorization_code(
131131- authz_metadata,
132132- dpop_key,
133133- code,
134134- stored_code_verifier
135135- ),
136136- {:ok, identity} <- IdentityResolver.resolve(tokens.did),
137137- # Make sure pds' issuer matches the stored one (just in case)
138138- pds <- DIDDocument.get_pds_endpoint(identity.document),
139139- {:ok, authz_server} <- OAuth.get_authorization_server(pds),
140140- true <- authz_server == stored_issuer do
141141- conn
142142- |> delete_resp_cookie("state", @oauth_cookie_opts)
143143- |> delete_resp_cookie("code_verifier", @oauth_cookie_opts)
144144- |> delete_resp_cookie("issuer", @oauth_cookie_opts)
145145- |> put_session(:atex_oauth, %{
146146- access_token: tokens.access_token,
147147- refresh_token: tokens.refresh_token,
148148- did: tokens.did,
149149- pds: pds,
150150- expires_at: tokens.expires_at,
151151- dpop_nonce: nonce,
152152- dpop_key: dpop_key
153153- })
154154- |> send_resp(200, "success!! hello #{tokens.did}")
155155- else
156156- false ->
157157- send_resp(conn, 400, "OAuth issuer does not match your PDS' authorization server")
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])
158224159159- err ->
160160- Logger.error("failed to validate oauth callback: #{inspect(err)}")
161161- send_resp(conn, 500, "Internal server error")
225225+ {:error, reason} ->
226226+ raise Atex.OAuth.Error,
227227+ message: "Failed to store OAuth session, reason: #{reason}",
228228+ reason: :session_store_failed
162229 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
163240 end
164241 end
242242+243243+ # TODO: logout route
165244end
+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
+91-40
lib/atex/oauth.ex
···289289290290 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.
292292+ OAuth flow. Results are cached for 1 hour to reduce load on third-party PDSs.
293293294294 ## Parameters
295295296296 - `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`)
297298298299 ## Returns
299300···302303 - `{:error, :invalid_metadata}` - Server returned invalid metadata
303304 - `{:error, reason}` - Error discovering authorization server
304305 """
305305- @spec get_authorization_server(String.t()) :: {:ok, String.t()} | {:error, any()}
306306- def get_authorization_server(pds_host) do
307307- "#{pds_host}/.well-known/oauth-protected-resource"
308308- |> Req.get()
309309- |> case do
310310- # TODO: what to do when multiple authorization servers?
311311- {:ok, %{body: %{"authorization_servers" => [authz_server | _]}}} -> {:ok, authz_server}
312312- {:ok, _} -> {:error, :invalid_metadata}
313313- err -> err
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
314339 end
315340 end
316341···319344320345 Retrieves the metadata from the authorization server's
321346 `.well-known/oauth-authorization-server` endpoint, providing endpoint URLs
322322- required for the OAuth flow.
347347+ required for the OAuth flow. Results are cached for 1 hour to reduce load on
348348+ third-party PDSs.
323349324350 ## Parameters
325351326352 - `issuer` - Authorization server issuer URL
353353+ - `fresh` - If `true`, bypasses the cache and fetches fresh data (default: `false`)
327354328355 ## Returns
329356···332359 - `{:error, :invalid_issuer}` - Issuer mismatch in metadata
333360 - `{:error, any()}` - Other error fetching metadata
334361 """
335335- @spec get_authorization_server_metadata(String.t()) ::
362362+ @spec get_authorization_server_metadata(String.t(), boolean()) ::
336363 {:ok, authorization_metadata()} | {:error, any()}
337337- def get_authorization_server_metadata(issuer) do
338338- "#{issuer}/.well-known/oauth-authorization-server"
339339- |> Req.get()
340340- |> case do
341341- {:ok,
342342- %{
343343- body: %{
344344- "issuer" => metadata_issuer,
345345- "pushed_authorization_request_endpoint" => par_endpoint,
346346- "token_endpoint" => token_endpoint,
347347- "authorization_endpoint" => authorization_endpoint
348348- }
349349- }} ->
350350- if issuer != metadata_issuer do
351351- {:error, :invaild_issuer}
352352- else
353353- {:ok,
354354- %{
355355- issuer: metadata_issuer,
356356- par_endpoint: par_endpoint,
357357- token_endpoint: token_endpoint,
358358- authorization_endpoint: authorization_endpoint
359359- }}
360360- end
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
361410362362- {:ok, _} ->
363363- {:error, :invalid_metadata}
411411+ case result do
412412+ {:ok, metadata} ->
413413+ Atex.OAuth.Cache.set_authorization_server_metadata(issuer, metadata)
414414+ {:ok, metadata}
364415365365- err ->
366366- err
416416+ error ->
417417+ error
367418 end
368419 end
369420
+205-141
lib/atex/xrpc/oauth_client.ex
···11defmodule 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+225 alias Atex.OAuth
33- alias Atex.XRPC
426 use TypedStruct
527628 @behaviour Atex.XRPC.Client
729830 typedstruct enforce: true do
99- field :endpoint, String.t()
1010- field :issuer, String.t()
1111- field :access_token, String.t()
1212- field :refresh_token, String.t()
1331 field :did, String.t()
1414- field :expires_at, NaiveDateTime.t()
1515- field :dpop_nonce, String.t() | nil, enforce: false
1616- field :dpop_key, JOSE.JWK.t()
1732 end
18331934 @doc """
2020- Create a new OAuthClient struct.
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+2148 """
2222- @spec new(
2323- String.t(),
2424- String.t(),
2525- String.t(),
2626- String.t(),
2727- NaiveDateTime.t(),
2828- JOSE.JWK.t(),
2929- String.t() | nil
3030- ) :: t()
3131- def new(endpoint, did, access_token, refresh_token, expires_at, dpop_key, dpop_nonce) do
3232- {:ok, issuer} = OAuth.get_authorization_server(endpoint)
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}}
33553434- %__MODULE__{
3535- endpoint: endpoint,
3636- issuer: issuer,
3737- access_token: access_token,
3838- refresh_token: refresh_token,
3939- did: did,
4040- expires_at: expires_at,
4141- dpop_nonce: dpop_nonce,
4242- dpop_key: dpop_key
4343- }
5656+ err ->
5757+ err
5858+ end
4459 end
45604661 @doc """
4747- Create an OAuthClient struct from a `Plug.Conn`.
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
48754949- Requires the conn to have passed through `Plug.Session` and
5050- `Plug.Conn.fetch_session/2` so that the session can be acquired and have the
5151- `atex_oauth` key fetched from it.
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)
52815353- Returns `:error` if the state is missing or is not the expected shape.
5482 """
5555- @spec from_conn(Plug.Conn.t()) :: {:ok, t()} | :error
8383+ @spec from_conn(Plug.Conn.t()) :: {:ok, t()} | :error | {:error, atom()}
5684 def from_conn(%Plug.Conn{} = conn) do
5757- oauth_state = Plug.Conn.get_session(conn, :atex_oauth)
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}
58905959- case oauth_state do
6060- %{
6161- access_token: access_token,
6262- refresh_token: refresh_token,
6363- did: did,
6464- pds: pds,
6565- expires_at: expires_at,
6666- dpop_nonce: dpop_nonce,
6767- dpop_key: dpop_key
6868- } ->
6969- {:ok, new(pds, did, access_token, refresh_token, expires_at, dpop_key, dpop_nonce)}
9191+ with_session_lock(client, fn ->
9292+ case maybe_refresh(client) do
9393+ {:ok, _session} -> {:ok, client}
9494+ _ -> {:error, :reauth}
9595+ end
9696+ end)
70977198 _ ->
7299 :error
···74101 end
7510276103 @doc """
7777- Updates a `Plug.Conn` session with the latest values from the client.
7878-7979- Ideally should be called at the end of routes where XRPC calls occur, in case
8080- the client has transparently refreshed, so that the user is always up to date.
8181- """
8282- @spec update_plug(Plug.Conn.t(), t()) :: Plug.Conn.t()
8383- def update_plug(%Plug.Conn{} = conn, %__MODULE__{} = client) do
8484- Plug.Conn.put_session(conn, :atex_oauth, %{
8585- access_token: client.access_token,
8686- refresh_token: client.refresh_token,
8787- did: client.did,
8888- pds: client.endpoint,
8989- expires_at: client.expires_at,
9090- dpop_nonce: client.dpop_nonce,
9191- dpop_key: client.dpop_key
9292- })
9393- end
9494-9595- @doc """
96104 Ask the client's OAuth server for a new set of auth tokens.
97105106106+ Fetches the session, refreshes the tokens, creates a new session with the
107107+ updated tokens, stores it, and returns the new session.
108108+98109 You shouldn't need to call this manually for the most part, the client does
9999- it's best to refresh automatically when it needs to.
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.
100113 """
101101- @spec refresh(t()) :: {:ok, t()} | {:error, any()}
114114+ @spec refresh(client :: t()) :: {:ok, OAuth.Session.t()} | {:error, any()}
102115 def refresh(%__MODULE__{} = client) do
103103- with {:ok, authz_server} <- OAuth.get_authorization_server(client.endpoint),
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),
104125 {:ok, %{token_endpoint: token_endpoint}} <-
105126 OAuth.get_authorization_server_metadata(authz_server) do
106127 case OAuth.refresh_token(
107107- client.refresh_token,
108108- client.dpop_key,
109109- client.issuer,
128128+ session.refresh_token,
129129+ session.dpop_key,
130130+ session.iss,
110131 token_endpoint
111132 ) do
112133 {:ok, tokens, nonce} ->
113113- {:ok,
114114- %{
115115- client
116116- | access_token: tokens.access_token,
117117- refresh_token: tokens.refresh_token,
118118- dpop_nonce: nonce
119119- }}
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
120149121150 err ->
122151 err
···124153 end
125154 end
126155156156+ @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+127175 @doc """
128128- See `Atex.XRPC.get/3`.
176176+ Make a GET request to an XRPC endpoint.
177177+178178+ See `Atex.XRPC.get/3` for details.
129179 """
130180 @impl true
131181 def get(%__MODULE__{} = client, resource, opts \\ []) do
132132- request(client, opts ++ [method: :get, url: XRPC.url(client.endpoint, resource)])
182182+ # TODO: Keyword.valiate to make sure :method isn't passed?
183183+ request(client, resource, opts ++ [method: :get])
133184 end
134185135186 @doc """
136136- See `Atex.XRPC.post/3`.
187187+ Make a POST request to an XRPC endpoint.
188188+189189+ See `Atex.XRPC.post/3` for details.
137190 """
138191 @impl true
139192 def post(%__MODULE__{} = client, resource, opts \\ []) do
140140- request(client, opts ++ [method: :post, url: XRPC.url(client.endpoint, resource)])
193193+ # Ditto
194194+ request(client, resource, opts ++ [method: :post])
141195 end
142196143143- @spec request(t(), keyword()) :: {:ok, Req.Response.t(), t()} | {:error, any(), any()}
144144- defp request(client, opts) do
145145- # Preemptively refresh token if it's about to expire
146146- with {:ok, client} <- maybe_refresh(client) do
147147- request = opts |> Req.new() |> put_auth(client.access_token)
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}
148219149149- case OAuth.request_protected_dpop_resource(
150150- request,
151151- client.issuer,
152152- client.access_token,
153153- client.dpop_key,
154154- client.dpop_nonce
155155- ) do
156156- {:ok, %{status: 200} = response, nonce} ->
157157- client = %{client | dpop_nonce: nonce}
158158- {:ok, response, client}
220220+ {:ok, response, nonce} ->
221221+ update_session_nonce(session, nonce)
222222+ handle_failure(client, request, response)
159223160160- {:ok, response, nonce} ->
161161- client = %{client | dpop_nonce: nonce}
162162- handle_failure(client, response, request)
224224+ err ->
225225+ err
226226+ end
163227164228 err ->
165229 err
166230 end
167167- end
231231+ end)
168232 end
169233170170- @spec handle_failure(t(), Req.Response.t(), Req.Request.t()) ::
171171- {:ok, Req.Response.t(), t()} | {:error, any(), t()}
172172- defp handle_failure(client, response, request) do
173173- IO.inspect(response, label: "got failure")
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
174241175175- if auth_error?(response.body) and client.refresh_token do
176176- case refresh(client) do
177177- {:ok, client} ->
242242+ defp handle_failure(client, request, response) do
243243+ if auth_error?(response) do
244244+ case do_refresh(client) do
245245+ {:ok, session} ->
178246 case OAuth.request_protected_dpop_resource(
179247 request,
180180- client.issuer,
181181- client.access_token,
182182- client.dpop_key,
183183- client.dpop_nonce
248248+ session.iss,
249249+ session.access_token,
250250+ session.dpop_key,
251251+ session.dpop_nonce
184252 ) do
185253 {:ok, %{status: 200} = response, nonce} ->
186186- {:ok, response, %{client | dpop_nonce: nonce}}
254254+ update_session_nonce(session, nonce)
255255+ {:ok, response, client}
187256188188- {:ok, response, nonce} ->
189189- {:error, response, %{client | dpop_nonce: nonce}}
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
190266191191- {:error, err} ->
192192- {:error, err, client}
267267+ err ->
268268+ err
193269 end
194270195271 err ->
···200276 end
201277 end
202278203203- @spec maybe_refresh(t(), integer()) :: {:ok, t()} | {:error, any()}
204204- defp maybe_refresh(%__MODULE__{expires_at: expires_at} = client, buffer_minutes \\ 5) do
205205- if token_expiring_soon?(expires_at, buffer_minutes) do
206206- refresh(client)
207207- else
208208- {:ok, client}
209209- end
210210- end
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\"")
211284212212- @spec token_expiring_soon?(NaiveDateTime.t(), integer()) :: boolean()
213213- defp token_expiring_soon?(expires_at, buffer_minutes) do
214214- now = NaiveDateTime.utc_now()
215215- expiry_threshold = NaiveDateTime.add(now, buffer_minutes * 60, :second)
285285+ defp auth_error?(_resp), do: false
216286217217- NaiveDateTime.compare(expires_at, expiry_threshold) in [:lt, :eq]
287287+ defp update_session_nonce(session, nonce) do
288288+ session = %{session | dpop_nonce: nonce}
289289+ :ok = OAuth.SessionStore.update(session)
290290+ session
218291 end
219219-220220- @spec auth_error?(body :: Req.Response.t()) :: boolean()
221221- defp auth_error?(%{status: status}) when status in [401, 403], do: true
222222- defp auth_error?(%{body: %{"error" => "InvalidToken"}}), do: true
223223- defp auth_error?(_response), do: false
224224-225225- @spec put_auth(Req.Request.t(), String.t()) :: Req.Request.t()
226226- defp put_auth(request, token),
227227- do: Req.Request.put_header(request, "authorization", "DPoP #{token}")
228292end
+142-13
lib/atex/xrpc.ex
···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"])
14141515- # OAuth-based client (coming next)
1616- oauth_client = Atex.XRPC.OAuthClient.new_from_oauth_tokens(endpoint, access_token, refresh_token, dpop_key)
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"])
18181919 ## Unauthenticated requests
···2424 {:ok, response} = Atex.XRPC.unauthed_get("https://bsky.social", "com.atproto.sync.getHead", params: [did: "did:plc:..."])
2525 """
26262727+ alias Atex.XRPC.Client
2828+2729 @doc """
2830 Perform a HTTP GET on a XRPC resource. Called a "query" in lexicons.
29313032 Accepts any client that implements `Atex.XRPC.Client` and returns
3133 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+ })
3252 """
3333- @spec get(Atex.XRPC.Client.client(), String.t(), keyword()) ::
3434- {:ok, Req.Response.t(), Atex.XRPC.Client.client()}
3535- | {:error, any(), Atex.XRPC.Client.client()}
3636- def get(client, name, opts \\ []) do
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
3759 client.__struct__.get(client, name, opts)
3860 end
39616262+ 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+4089 @doc """
4141- Perform a HTTP POST on a XRPC resource. Called a "prodecure" in lexicons.
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+ )
421194343- Accepts any client that implements `Atex.XRPC.Client` and returns
4444- both the response and the (potentially updated) client.
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+ })
45133 """
4646- @spec post(Atex.XRPC.Client.client(), String.t(), keyword()) ::
4747- {:ok, Req.Response.t(), Atex.XRPC.Client.client()}
4848- | {:error, any(), Atex.XRPC.Client.client()}
4949- def post(client, name, opts \\ []) do
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
50140 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
51180 end
5218153182 @doc """
···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
···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