From 474177902bbb07667a717389c53015ba9bbcd132 Mon Sep 17 00:00:00 2001 From: Ashlynne Mitchell Date: Sun, 28 Dec 2025 02:12:40 +1100 Subject: [PATCH] feat: cache for authorization server information --- .formatter.exs | 1 + .gitignore | 1 - AGENTS.md | 39 ++++++++++++ CHANGELOG.md | 5 ++ lib/atex/application.ex | 6 +- lib/atex/oauth.ex | 131 ++++++++++++++++++++++++++++------------ lib/atex/oauth/cache.ex | 127 ++++++++++++++++++++++++++++++++++++++ mix.exs | 5 +- mix.lock | 3 +- 9 files changed, 273 insertions(+), 45 deletions(-) create mode 100644 AGENTS.md create mode 100644 lib/atex/oauth/cache.ex diff --git a/.formatter.exs b/.formatter.exs index 550e6e1..5d7cac2 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -2,6 +2,7 @@ [ inputs: ["{mix,.formatter,.credo}.exs", "{config,examples,lib,test}/**/*.{ex,exs}"], import_deps: [:typedstruct, :peri, :plug], + excludes: ["lib/atproto/**/*"], export: [ locals_without_parens: [deflexicon: 1] ] diff --git a/.gitignore b/.gitignore index 70e897d..96b4619 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,5 @@ lexicons secrets .DS_Store CLAUDE.md -AGENTS.md tmp temp \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..cf53021 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,39 @@ +# Agent Guidelines for atex + +## Commands + +- **Test**: `mix test` (all), `mix test test/path/to/file_test.exs` (single + file), `mix test test/path/to/file_test.exs:42` (single test at line) +- **Format**: `mix format` (auto-formats all code) +- **Lint**: `mix credo` (static analysis, TODO checks disabled) +- **Compile**: `mix compile` +- **Docs**: `mix docs` + +## Code Style + +- **Imports**: Use `alias` for modules (e.g., + `alias Atex.Config.OAuth, as: Config`), import macros sparingly +- **Formatting**: Elixir 1.18+, auto-formatted via `.formatter.exs` with + `import_deps: [:typedstruct, :peri, :plug]` +- **Naming**: snake_case for functions/variables, PascalCase for modules, + descriptive names (e.g., `authorization_metadata`, not `auth_meta`) +- **Types**: Use `@type` and `@spec` for all public functions; leverage + TypedStruct for structs +- **Moduledocs**: All public modules need `@moduledoc`, public functions need + `@doc` with examples +- **Error Handling**: Return `{:ok, result}` or `{:error, reason}` tuples; use + pattern matching in case statements +- **Pattern Matching**: Prefer pattern matching over conditionals; use guards + when appropriate +- **Macros**: Use `deflexicon` macro for lexicon definitions; use `defschema` + (from Peri) for validation schemas +- **Tests**: Async by default (`use ExUnit.Case, async: true`), use doctests + where applicable +- **Dependencies**: Core deps include Peri (validation), Req (HTTP), JOSE + (JWT/OAuth), TypedStruct (structs) + +## Important Notes + +- **DO NOT modify** `lib/atproto/**/` - autogenerated from official AT Protocol + lexicons +- **Update CHANGELOG.md** when adding features, changes, or fixes diff --git a/CHANGELOG.md b/CHANGELOG.md index 5211566..88ae12c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,11 @@ and this project adheres to - `Atex.OAuth.Error` exception module for OAuth flow errors. Contains both a human-readable `message` string and a machine-readable `reason` atom for error handling. +- `Atex.OAuth.Cache` module provides TTL caching for OAuth authorization server + metadata with a 1-hour default TTL to reduce load on third-party PDSs. +- `Atex.OAuth.get_authorization_server/2` and + `Atex.OAuth.get_authorization_server_metadata/2` now support an optional + `fresh` parameter to bypass the cache when needed. ### Changed diff --git a/lib/atex/application.ex b/lib/atex/application.ex index 18acd7e..98121d0 100644 --- a/lib/atex/application.ex +++ b/lib/atex/application.ex @@ -4,7 +4,11 @@ defmodule Atex.Application do use Application def start(_type, _args) do - children = [Atex.IdentityResolver.Cache] + children = [ + Atex.IdentityResolver.Cache, + Atex.OAuth.Cache + ] + Supervisor.start_link(children, strategy: :one_for_one) end end diff --git a/lib/atex/oauth.ex b/lib/atex/oauth.ex index f8125f7..7288c0f 100644 --- a/lib/atex/oauth.ex +++ b/lib/atex/oauth.ex @@ -289,11 +289,12 @@ defmodule Atex.OAuth do Makes a request to the PDS's `.well-known/oauth-protected-resource` endpoint to discover the associated authorization server that should be used for the - OAuth flow. + OAuth flow. Results are cached for 1 hour to reduce load on third-party PDSs. ## Parameters - `pds_host` - Base URL of the PDS (e.g., "https://bsky.social") + - `fresh` - If `true`, bypasses the cache and fetches fresh data (default: `false`) ## Returns @@ -302,15 +303,39 @@ defmodule Atex.OAuth do - `{:error, :invalid_metadata}` - Server returned invalid metadata - `{:error, reason}` - Error discovering authorization server """ - @spec get_authorization_server(String.t()) :: {:ok, String.t()} | {:error, any()} - def get_authorization_server(pds_host) do - "#{pds_host}/.well-known/oauth-protected-resource" - |> Req.get() - |> case do - # TODO: what to do when multiple authorization servers? - {:ok, %{body: %{"authorization_servers" => [authz_server | _]}}} -> {:ok, authz_server} - {:ok, _} -> {:error, :invalid_metadata} - err -> err + @spec get_authorization_server(String.t(), boolean()) :: {:ok, String.t()} | {:error, any()} + def get_authorization_server(pds_host, fresh \\ false) do + if fresh do + fetch_authorization_server(pds_host) + else + case Atex.OAuth.Cache.get_authorization_server(pds_host) do + {:ok, authz_server} -> + {:ok, authz_server} + + {:error, :not_found} -> + fetch_authorization_server(pds_host) + end + end + end + + defp fetch_authorization_server(pds_host) do + result = + "#{pds_host}/.well-known/oauth-protected-resource" + |> Req.get() + |> case do + # TODO: what to do when multiple authorization servers? + {:ok, %{body: %{"authorization_servers" => [authz_server | _]}}} -> {:ok, authz_server} + {:ok, _} -> {:error, :invalid_metadata} + err -> err + end + + case result do + {:ok, authz_server} -> + Atex.OAuth.Cache.set_authorization_server(pds_host, authz_server) + {:ok, authz_server} + + error -> + error end end @@ -319,11 +344,13 @@ defmodule Atex.OAuth do Retrieves the metadata from the authorization server's `.well-known/oauth-authorization-server` endpoint, providing endpoint URLs - required for the OAuth flow. + required for the OAuth flow. Results are cached for 1 hour to reduce load on + third-party PDSs. ## Parameters - `issuer` - Authorization server issuer URL + - `fresh` - If `true`, bypasses the cache and fetches fresh data (default: `false`) ## Returns @@ -332,38 +359,62 @@ defmodule Atex.OAuth do - `{:error, :invalid_issuer}` - Issuer mismatch in metadata - `{:error, any()}` - Other error fetching metadata """ - @spec get_authorization_server_metadata(String.t()) :: + @spec get_authorization_server_metadata(String.t(), boolean()) :: {:ok, authorization_metadata()} | {:error, any()} - def get_authorization_server_metadata(issuer) do - "#{issuer}/.well-known/oauth-authorization-server" - |> Req.get() - |> case do - {:ok, - %{ - body: %{ - "issuer" => metadata_issuer, - "pushed_authorization_request_endpoint" => par_endpoint, - "token_endpoint" => token_endpoint, - "authorization_endpoint" => authorization_endpoint - } - }} -> - if issuer != metadata_issuer do - {:error, :invaild_issuer} - else - {:ok, - %{ - issuer: metadata_issuer, - par_endpoint: par_endpoint, - token_endpoint: token_endpoint, - authorization_endpoint: authorization_endpoint - }} - end + def get_authorization_server_metadata(issuer, fresh \\ false) do + if fresh do + fetch_authorization_server_metadata(issuer) + else + case Atex.OAuth.Cache.get_authorization_server_metadata(issuer) do + {:ok, metadata} -> + {:ok, metadata} + + {:error, :not_found} -> + fetch_authorization_server_metadata(issuer) + end + end + end - {:ok, _} -> - {:error, :invalid_metadata} + defp fetch_authorization_server_metadata(issuer) do + result = + "#{issuer}/.well-known/oauth-authorization-server" + |> Req.get() + |> case do + {:ok, + %{ + body: %{ + "issuer" => metadata_issuer, + "pushed_authorization_request_endpoint" => par_endpoint, + "token_endpoint" => token_endpoint, + "authorization_endpoint" => authorization_endpoint + } + }} -> + if issuer != metadata_issuer do + {:error, :invaild_issuer} + else + {:ok, + %{ + issuer: metadata_issuer, + par_endpoint: par_endpoint, + token_endpoint: token_endpoint, + authorization_endpoint: authorization_endpoint + }} + end - err -> - err + {:ok, _} -> + {:error, :invalid_metadata} + + err -> + err + end + + case result do + {:ok, metadata} -> + Atex.OAuth.Cache.set_authorization_server_metadata(issuer, metadata) + {:ok, metadata} + + error -> + error end end diff --git a/lib/atex/oauth/cache.ex b/lib/atex/oauth/cache.ex new file mode 100644 index 0000000..4473d1f --- /dev/null +++ b/lib/atex/oauth/cache.ex @@ -0,0 +1,127 @@ +defmodule Atex.OAuth.Cache do + @moduledoc """ + TTL cache for OAuth authorization server information. + + This module manages two separate ConCache instances: + - Authorization server cache (stores PDS -> authz server mappings) + - Authorization metadata cache (stores authz server -> metadata mappings) + + Both caches use a 1-hour TTL to reduce load on third-party PDSs. + """ + + use Supervisor + + @authz_server_cache :oauth_authz_server_cache + @authz_metadata_cache :oauth_authz_metadata_cache + @ttl_ms :timer.hours(1) + + @doc """ + Starts the OAuth cache supervisor. + """ + def start_link(opts) do + Supervisor.start_link(__MODULE__, opts, name: __MODULE__) + end + + @impl Supervisor + def init(_opts) do + children = [ + Supervisor.child_spec( + {ConCache, + [ + name: @authz_server_cache, + ttl_check_interval: :timer.minutes(5), + global_ttl: @ttl_ms + ]}, + id: :authz_server_cache + ), + Supervisor.child_spec( + {ConCache, + [ + name: @authz_metadata_cache, + ttl_check_interval: :timer.seconds(30), + global_ttl: @ttl_ms + ]}, + id: :authz_metadata_cache + ) + ] + + Supervisor.init(children, strategy: :one_for_one) + end + + @doc """ + Get authorization server from cache. + + ## Parameters + + - `pds_host` - Base URL of the PDS (e.g., "https://bsky.social") + + ## Returns + + - `{:ok, authorization_server}` - Successfully retrieved from cache + - `{:error, :not_found}` - Not present in cache + """ + @spec get_authorization_server(String.t()) :: {:ok, String.t()} | {:error, :not_found} + def get_authorization_server(pds_host) do + case ConCache.get(@authz_server_cache, pds_host) do + nil -> {:error, :not_found} + value -> {:ok, value} + end + end + + @doc """ + Store authorization server in cache. + + ## Parameters + + - `pds_host` - Base URL of the PDS + - `authorization_server` - Authorization server URL to cache + + ## Returns + + - `:ok` + """ + @spec set_authorization_server(String.t(), String.t()) :: :ok + def set_authorization_server(pds_host, authorization_server) do + ConCache.put(@authz_server_cache, pds_host, authorization_server) + :ok + end + + @doc """ + Get authorization server metadata from cache. + + ## Parameters + + - `issuer` - Authorization server issuer URL + + ## Returns + + - `{:ok, metadata}` - Successfully retrieved from cache + - `{:error, :not_found}` - Not present in cache + """ + @spec get_authorization_server_metadata(String.t()) :: + {:ok, Atex.OAuth.authorization_metadata()} | {:error, :not_found} + def get_authorization_server_metadata(issuer) do + case ConCache.get(@authz_metadata_cache, issuer) do + nil -> {:error, :not_found} + value -> {:ok, value} + end + end + + @doc """ + Store authorization server metadata in cache. + + ## Parameters + + - `issuer` - Authorization server issuer URL + - `metadata` - Authorization server metadata to cache + + ## Returns + + - `:ok` + """ + @spec set_authorization_server_metadata(String.t(), Atex.OAuth.authorization_metadata()) :: :ok + def set_authorization_server_metadata(issuer, metadata) do + ConCache.put(@authz_metadata_cache, issuer, metadata) + :ok + end +end diff --git a/mix.exs b/mix.exs index 4ce06f7..909d703 100644 --- a/mix.exs +++ b/mix.exs @@ -35,11 +35,12 @@ defmodule Atex.MixProject do {:typedstruct, "~> 0.5"}, {:ex_cldr, "~> 2.42"}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, - {:ex_doc, "~> 0.34", only: :dev, runtime: false, warn_if_outdated: true}, + {:ex_doc, "~> 0.39", only: :dev, runtime: false, warn_if_outdated: true}, {:plug, "~> 1.18"}, {:jason, "~> 1.4"}, {:jose, "~> 1.11"}, - {:bandit, "~> 1.0", only: [:dev, :test]} + {:bandit, "~> 1.0", only: [:dev, :test]}, + {:con_cache, "~> 1.1"} ] end diff --git a/mix.lock b/mix.lock index 8affefa..f503adc 100644 --- a/mix.lock +++ b/mix.lock @@ -2,11 +2,12 @@ "bandit": {:hex, :bandit, "1.8.0", "c2e93d7e3c5c794272fa4623124f827c6f24b643acc822be64c826f9447d92fb", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "8458ff4eed20ff2a2ea69d4854883a077c33ea42b51f6811b044ceee0fa15422"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "cldr_utils": {:hex, :cldr_utils, "2.28.3", "d0ac5ed25913349dfaca8b7fe14722d588d8ccfa3e335b0510c7cc3f3c54d4e6", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.5", [hex: :certifi, repo: "hexpm", optional: true]}, {:decimal, "~> 1.9 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "40083cd9a5d187f12d675cfeeb39285f0d43e7b7f2143765161b72205d57ffb5"}, + "con_cache": {:hex, :con_cache, "1.1.1", "9f47a68dfef5ac3bbff8ce2c499869dbc5ba889dadde6ac4aff8eb78ddaf6d82", [:mix], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1def4d1bec296564c75b5bbc60a19f2b5649d81bfa345a2febcc6ae380e8ae15"}, "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, "ex_cldr": {:hex, :ex_cldr, "2.43.0", "8700031e30a03501cf65f7ba7c8287bb67339d03559f3108f3c54fe86d926b19", [:mix], [{:cldr_utils, "~> 2.28", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.19", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: true]}], "hexpm", "1524eb01275b89473ee5f53fcc6169bae16e4a5267ef109229f37694799e0b20"}, - "ex_doc": {:hex, :ex_doc, "0.38.3", "ddafe36b8e9fe101c093620879f6604f6254861a95133022101c08e75e6c759a", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "ecaa785456a67f63b4e7d7f200e8832fa108279e7eb73fd9928e7e66215a01f9"}, + "ex_doc": {:hex, :ex_doc, "0.39.3", "519c6bc7e84a2918b737aec7ef48b96aa4698342927d080437f61395d361dcee", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "0590955cf7ad3b625780ee1c1ea627c28a78948c6c0a9b0322bd976a079996e1"}, "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, -- 2.51.2