···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+- **Error Handling**: Return `{:ok, result}` or `{:error, reason}` tuples; use
2525+ pattern matching in case statements
2626+- **Pattern Matching**: Prefer pattern matching over conditionals; use guards
2727+ when appropriate
2828+- **Macros**: Use `deflexicon` macro for lexicon definitions; use `defschema`
2929+ (from Peri) for validation schemas
3030+- **Tests**: Async by default (`use ExUnit.Case, async: true`), use doctests
3131+ where applicable
3232+- **Dependencies**: Core deps include Peri (validation), Req (HTTP), JOSE
3333+ (JWT/OAuth), TypedStruct (structs)
3434+3535+## Important Notes
3636+3737+- **DO NOT modify** `lib/atproto/**/` - autogenerated from official AT Protocol
3838+ lexicons
3939+- **Update CHANGELOG.md** when adding features, changes, or fixes
+5
CHANGELOG.md
···2828- `Atex.OAuth.Error` exception module for OAuth flow errors. Contains both a
2929 human-readable `message` string and a machine-readable `reason` atom for error
3030 handling.
3131+- `Atex.OAuth.Cache` module provides TTL caching for OAuth authorization server
3232+ metadata with a 1-hour default TTL to reduce load on third-party PDSs.
3333+- `Atex.OAuth.get_authorization_server/2` and
3434+ `Atex.OAuth.get_authorization_server_metadata/2` now support an optional
3535+ `fresh` parameter to bypass the cache when needed.
31363237### Changed
3338
+5-1
lib/atex/application.ex
···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+ ]
1111+812 Supervisor.start_link(children, strategy: :one_for_one)
913 end
1014end
+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
+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