A set of utilities for working with the AT Protocol in Elixir.

refactor!: oauth client now uses session store

ovyerus.com 51735333 cbe6b2ee

verified
+1
.gitignore
··· 6 6 *.ez 7 7 atex-*.tar 8 8 /tmp/ 9 + /priv/dets/ 9 10 10 11 .envrc 11 12 .direnv
+1
AGENTS.md
··· 21 21 TypedStruct for structs 22 22 - **Moduledocs**: All public modules need `@moduledoc`, public functions need 23 23 `@doc` with examples 24 + - When writing lists in documentation, use `-` as the list character. 24 25 - **Error Handling**: Return `{:ok, result}` or `{:error, reason}` tuples; use 25 26 pattern matching in case statements 26 27 - **Pattern Matching**: Prefer pattern matching over conditionals; use guards
+6 -2
CHANGELOG.md
··· 10 10 11 11 ### Breaking Changes 12 12 13 - - Rename `Atex.XRPC.OAuthClient.update_plug/2` to `update_conn/2`, to match the 14 - naming of `from_conn/1`. 15 13 - `Atex.OAuth.Plug` now raises `Atex.OAuth.Error` exceptions instead of handling 16 14 error situations internally. Applications should implement `Plug.ErrorHandler` 17 15 to catch and gracefully handle them. 16 + - `Atex.OAuth.Plug` now saves only the user's DID in the session instead of the 17 + entire OAuth session object. Applications must use `Atex.OAuth.SessionStore` 18 + to manage OAuth sessions. 19 + - `Atex.XRPC.OAuthClient` has been overhauled to use `Atex.OAuth.SessionStore` 20 + for retrieving and managing OAuth sessions, making it easier to use with not 21 + needing to manually keep a Plug session in sync. 18 22 19 23 ### Added 20 24
+205 -141
lib/atex/xrpc/oauth_client.ex
··· 1 1 defmodule Atex.XRPC.OAuthClient do 2 + @moduledoc """ 3 + OAuth client for making authenticated XRPC requests to AT Protocol servers. 4 + 5 + The client contains a user's DID and talks to `Atex.OAuth.SessionStore` to 6 + retrieve sessions internally to make requests. As a result, it will only work 7 + for users that have gone through an OAuth flow; see `Atex.OAuth.Plug` for an 8 + existing method of doing that. 9 + 10 + The entire OAuth session lifecycle is handled transparently, with the access 11 + token being refreshed automatically as required. 12 + 13 + ## Usage 14 + 15 + # Create from an existing OAuth session 16 + {:ok, client} = Atex.XRPC.OAuthClient.new("did:plc:abc123") 17 + 18 + # Or extract from a Plug.Conn after OAuth flow 19 + {:ok, client} = Atex.XRPC.OAuthClient.from_conn(conn) 20 + 21 + # Make XRPC requests 22 + {:ok, response, client} = Atex.XRPC.get(client, "com.atproto.repo.listRecords") 23 + """ 24 + 2 25 alias Atex.OAuth 3 - alias Atex.XRPC 4 26 use TypedStruct 5 27 6 28 @behaviour Atex.XRPC.Client 7 29 8 30 typedstruct enforce: true do 9 - field :endpoint, String.t() 10 - field :issuer, String.t() 11 - field :access_token, String.t() 12 - field :refresh_token, String.t() 13 31 field :did, String.t() 14 - field :expires_at, NaiveDateTime.t() 15 - field :dpop_nonce, String.t() | nil, enforce: false 16 - field :dpop_key, JOSE.JWK.t() 17 32 end 18 33 19 34 @doc """ 20 - Create a new OAuthClient struct. 35 + Create a new OAuthClient from a DID. 36 + 37 + Validates that an OAuth session exists for the given DID in the session store 38 + before returning the client struct. 39 + 40 + ## Examples 41 + 42 + iex> Atex.XRPC.OAuthClient.new("did:plc:abc123") 43 + {:ok, %Atex.XRPC.OAuthClient{did: "did:plc:abc123"}} 44 + 45 + iex> Atex.XRPC.OAuthClient.new("did:plc:nosession") 46 + {:error, :not_found} 47 + 21 48 """ 22 - @spec new( 23 - String.t(), 24 - String.t(), 25 - String.t(), 26 - String.t(), 27 - NaiveDateTime.t(), 28 - JOSE.JWK.t(), 29 - String.t() | nil 30 - ) :: t() 31 - def new(endpoint, did, access_token, refresh_token, expires_at, dpop_key, dpop_nonce) do 32 - {:ok, issuer} = OAuth.get_authorization_server(endpoint) 49 + @spec new(String.t()) :: {:ok, t()} | {:error, atom()} 50 + def new(did) do 51 + # Make sure session exists before returning a struct 52 + case Atex.OAuth.SessionStore.get(did) do 53 + {:ok, _session} -> 54 + {:ok, %__MODULE__{did: did}} 33 55 34 - %__MODULE__{ 35 - endpoint: endpoint, 36 - issuer: issuer, 37 - access_token: access_token, 38 - refresh_token: refresh_token, 39 - did: did, 40 - expires_at: expires_at, 41 - dpop_nonce: dpop_nonce, 42 - dpop_key: dpop_key 43 - } 56 + err -> 57 + err 58 + end 44 59 end 45 60 46 61 @doc """ 47 - Create an OAuthClient struct from a `Plug.Conn`. 62 + Create an OAuthClient from a `Plug.Conn`. 63 + 64 + Extracts the DID from the session (stored under `:atex_session` key) and validates 65 + that the OAuth session is still valid. If the token is expired or expiring soon, 66 + it attempts to refresh it. 67 + 68 + Requires the conn to have passed through `Plug.Session` and `Plug.Conn.fetch_session/2`. 69 + 70 + ## Returns 71 + 72 + - `{:ok, client}` - Successfully created client 73 + - `{:error, :reauth}` - Session exists but refresh failed, user needs to re-authenticate 74 + - `:error` - No session found in conn 48 75 49 - Requires the conn to have passed through `Plug.Session` and 50 - `Plug.Conn.fetch_session/2` so that the session can be acquired and have the 51 - `atex_oauth` key fetched from it. 76 + ## Examples 77 + 78 + # After OAuth flow completes 79 + conn = Plug.Conn.put_session(conn, :atex_session, "did:plc:abc123") 80 + {:ok, client} = Atex.XRPC.OAuthClient.from_conn(conn) 52 81 53 - Returns `:error` if the state is missing or is not the expected shape. 54 82 """ 55 - @spec from_conn(Plug.Conn.t()) :: {:ok, t()} | :error 83 + @spec from_conn(Plug.Conn.t()) :: {:ok, t()} | :error | {:error, atom()} 56 84 def from_conn(%Plug.Conn{} = conn) do 57 - oauth_state = Plug.Conn.get_session(conn, :atex_oauth) 85 + oauth_did = Plug.Conn.get_session(conn, :atex_session) 86 + 87 + case oauth_did do 88 + did when is_binary(did) -> 89 + client = %__MODULE__{did: did} 58 90 59 - case oauth_state do 60 - %{ 61 - access_token: access_token, 62 - refresh_token: refresh_token, 63 - did: did, 64 - pds: pds, 65 - expires_at: expires_at, 66 - dpop_nonce: dpop_nonce, 67 - dpop_key: dpop_key 68 - } -> 69 - {:ok, new(pds, did, access_token, refresh_token, expires_at, dpop_key, dpop_nonce)} 91 + with_session_lock(client, fn -> 92 + case maybe_refresh(client) do 93 + {:ok, _session} -> {:ok, client} 94 + _ -> {:error, :reauth} 95 + end 96 + end) 70 97 71 98 _ -> 72 99 :error ··· 74 101 end 75 102 76 103 @doc """ 77 - Updates a `Plug.Conn` session with the latest values from the client. 78 - 79 - Ideally should be called at the end of routes where XRPC calls occur, in case 80 - the client has transparently refreshed, so that the user is always up to date. 81 - """ 82 - @spec update_conn(Plug.Conn.t(), t()) :: Plug.Conn.t() 83 - def update_conn(%Plug.Conn{} = conn, %__MODULE__{} = client) do 84 - Plug.Conn.put_session(conn, :atex_oauth, %{ 85 - access_token: client.access_token, 86 - refresh_token: client.refresh_token, 87 - did: client.did, 88 - pds: client.endpoint, 89 - expires_at: client.expires_at, 90 - dpop_nonce: client.dpop_nonce, 91 - dpop_key: client.dpop_key 92 - }) 93 - end 94 - 95 - @doc """ 96 104 Ask the client's OAuth server for a new set of auth tokens. 97 105 106 + Fetches the session, refreshes the tokens, creates a new session with the 107 + updated tokens, stores it, and returns the new session. 108 + 98 109 You shouldn't need to call this manually for the most part, the client does 99 - it's best to refresh automatically when it needs to. 110 + its best to refresh automatically when it needs to. 111 + 112 + This function acquires a lock on the session to prevent concurrent refresh attempts. 100 113 """ 101 - @spec refresh(t()) :: {:ok, t()} | {:error, any()} 114 + @spec refresh(client :: t()) :: {:ok, OAuth.Session.t()} | {:error, any()} 102 115 def refresh(%__MODULE__{} = client) do 103 - with {:ok, authz_server} <- OAuth.get_authorization_server(client.endpoint), 116 + with_session_lock(client, fn -> 117 + do_refresh(client) 118 + end) 119 + end 120 + 121 + @spec do_refresh(t()) :: {:ok, OAuth.Session.t()} | {:error, any()} 122 + defp do_refresh(%__MODULE__{did: did}) do 123 + with {:ok, session} <- OAuth.SessionStore.get(did), 124 + {:ok, authz_server} <- OAuth.get_authorization_server(session.aud), 104 125 {:ok, %{token_endpoint: token_endpoint}} <- 105 126 OAuth.get_authorization_server_metadata(authz_server) do 106 127 case OAuth.refresh_token( 107 - client.refresh_token, 108 - client.dpop_key, 109 - client.issuer, 128 + session.refresh_token, 129 + session.dpop_key, 130 + session.iss, 110 131 token_endpoint 111 132 ) do 112 133 {:ok, tokens, nonce} -> 113 - {:ok, 114 - %{ 115 - client 116 - | access_token: tokens.access_token, 117 - refresh_token: tokens.refresh_token, 118 - dpop_nonce: nonce 119 - }} 134 + new_session = %OAuth.Session{ 135 + iss: session.iss, 136 + aud: session.aud, 137 + sub: tokens.did, 138 + access_token: tokens.access_token, 139 + refresh_token: tokens.refresh_token, 140 + expires_at: tokens.expires_at, 141 + dpop_key: session.dpop_key, 142 + dpop_nonce: nonce 143 + } 144 + 145 + case OAuth.SessionStore.update(new_session) do 146 + :ok -> {:ok, new_session} 147 + err -> err 148 + end 120 149 121 150 err -> 122 151 err ··· 124 153 end 125 154 end 126 155 156 + @spec maybe_refresh(t(), integer()) :: {:ok, OAuth.Session.t()} | {:error, any()} 157 + defp maybe_refresh(%__MODULE__{did: did} = client, buffer_minutes \\ 5) do 158 + with {:ok, session} <- OAuth.SessionStore.get(did) do 159 + if token_expiring_soon?(session.expires_at, buffer_minutes) do 160 + do_refresh(client) 161 + else 162 + {:ok, session} 163 + end 164 + end 165 + end 166 + 167 + @spec token_expiring_soon?(NaiveDateTime.t(), integer()) :: boolean() 168 + defp token_expiring_soon?(expires_at, buffer_minutes) do 169 + now = NaiveDateTime.utc_now() 170 + expiry_threshold = NaiveDateTime.add(now, buffer_minutes * 60, :second) 171 + 172 + NaiveDateTime.compare(expires_at, expiry_threshold) in [:lt, :eq] 173 + end 174 + 127 175 @doc """ 128 - See `Atex.XRPC.get/3`. 176 + Make a GET request to an XRPC endpoint. 177 + 178 + See `Atex.XRPC.get/3` for details. 129 179 """ 130 180 @impl true 131 181 def get(%__MODULE__{} = client, resource, opts \\ []) do 132 - request(client, opts ++ [method: :get, url: XRPC.url(client.endpoint, resource)]) 182 + # TODO: Keyword.valiate to make sure :method isn't passed? 183 + request(client, resource, opts ++ [method: :get]) 133 184 end 134 185 135 186 @doc """ 136 - See `Atex.XRPC.post/3`. 187 + Make a POST request to an XRPC endpoint. 188 + 189 + See `Atex.XRPC.post/3` for details. 137 190 """ 138 191 @impl true 139 192 def post(%__MODULE__{} = client, resource, opts \\ []) do 140 - request(client, opts ++ [method: :post, url: XRPC.url(client.endpoint, resource)]) 193 + # Ditto 194 + request(client, resource, opts ++ [method: :post]) 141 195 end 142 196 143 - @spec request(t(), keyword()) :: {:ok, Req.Response.t(), t()} | {:error, any(), any()} 144 - defp request(client, opts) do 145 - # Preemptively refresh token if it's about to expire 146 - with {:ok, client} <- maybe_refresh(client) do 147 - request = opts |> Req.new() |> put_auth(client.access_token) 197 + defp request(%__MODULE__{} = client, resource, opts) do 198 + with_session_lock(client, fn -> 199 + case maybe_refresh(client) do 200 + {:ok, session} -> 201 + url = Atex.XRPC.url(session.aud, resource) 202 + 203 + request = 204 + opts 205 + |> Keyword.put(:url, url) 206 + |> Req.new() 207 + |> Req.Request.put_header("authorization", "DPoP #{session.access_token}") 208 + 209 + case OAuth.request_protected_dpop_resource( 210 + request, 211 + session.iss, 212 + session.access_token, 213 + session.dpop_key, 214 + session.dpop_nonce 215 + ) do 216 + {:ok, %{status: 200} = response, nonce} -> 217 + update_session_nonce(session, nonce) 218 + {:ok, response, client} 148 219 149 - case OAuth.request_protected_dpop_resource( 150 - request, 151 - client.issuer, 152 - client.access_token, 153 - client.dpop_key, 154 - client.dpop_nonce 155 - ) do 156 - {:ok, %{status: 200} = response, nonce} -> 157 - client = %{client | dpop_nonce: nonce} 158 - {:ok, response, client} 220 + {:ok, response, nonce} -> 221 + update_session_nonce(session, nonce) 222 + handle_failure(client, request, response) 159 223 160 - {:ok, response, nonce} -> 161 - client = %{client | dpop_nonce: nonce} 162 - handle_failure(client, response, request) 224 + err -> 225 + err 226 + end 163 227 164 228 err -> 165 229 err 166 230 end 167 - end 231 + end) 168 232 end 169 233 170 - @spec handle_failure(t(), Req.Response.t(), Req.Request.t()) :: 171 - {:ok, Req.Response.t(), t()} | {:error, any(), t()} 172 - defp handle_failure(client, response, request) do 173 - IO.inspect(response, label: "got failure") 234 + # Execute a function with an exclusive lock on the session identified by the 235 + # client's DID. This ensures that concurrent requests for the same user don't 236 + # race during token refresh. 237 + @spec with_session_lock(t(), (-> result)) :: result when result: any() 238 + defp with_session_lock(%__MODULE__{did: did}, fun) do 239 + Mutex.with_lock(Atex.SessionMutex, did, fun) 240 + end 174 241 175 - if auth_error?(response.body) and client.refresh_token do 176 - case refresh(client) do 177 - {:ok, client} -> 242 + defp handle_failure(client, request, response) do 243 + if auth_error?(response) do 244 + case do_refresh(client) do 245 + {:ok, session} -> 178 246 case OAuth.request_protected_dpop_resource( 179 247 request, 180 - client.issuer, 181 - client.access_token, 182 - client.dpop_key, 183 - client.dpop_nonce 248 + session.iss, 249 + session.access_token, 250 + session.dpop_key, 251 + session.dpop_nonce 184 252 ) do 185 253 {:ok, %{status: 200} = response, nonce} -> 186 - {:ok, response, %{client | dpop_nonce: nonce}} 254 + update_session_nonce(session, nonce) 255 + {:ok, response, client} 187 256 188 - {:ok, response, nonce} -> 189 - {:error, response, %{client | dpop_nonce: nonce}} 257 + {:ok, response, _nonce} -> 258 + if auth_error?(response) do 259 + # We tried to refresh the token once but it's still failing 260 + # Clear session and prompt dev to reauth or something 261 + OAuth.SessionStore.delete(session) 262 + {:error, response, :expired} 263 + else 264 + {:error, response, client} 265 + end 190 266 191 - {:error, err} -> 192 - {:error, err, client} 267 + err -> 268 + err 193 269 end 194 270 195 271 err -> ··· 200 276 end 201 277 end 202 278 203 - @spec maybe_refresh(t(), integer()) :: {:ok, t()} | {:error, any()} 204 - defp maybe_refresh(%__MODULE__{expires_at: expires_at} = client, buffer_minutes \\ 5) do 205 - if token_expiring_soon?(expires_at, buffer_minutes) do 206 - refresh(client) 207 - else 208 - {:ok, client} 209 - end 210 - end 279 + @spec auth_error?(Req.Response.t()) :: boolean() 280 + defp auth_error?(%{status: 401, headers: %{"www-authenticate" => [www_auth]}}), 281 + do: 282 + (String.starts_with?(www_auth, "Bearer") or String.starts_with?(www_auth, "DPoP")) and 283 + String.contains?(www_auth, "error=\"invalid_token\"") 211 284 212 - @spec token_expiring_soon?(NaiveDateTime.t(), integer()) :: boolean() 213 - defp token_expiring_soon?(expires_at, buffer_minutes) do 214 - now = NaiveDateTime.utc_now() 215 - expiry_threshold = NaiveDateTime.add(now, buffer_minutes * 60, :second) 285 + defp auth_error?(_resp), do: false 216 286 217 - NaiveDateTime.compare(expires_at, expiry_threshold) in [:lt, :eq] 287 + defp update_session_nonce(session, nonce) do 288 + session = %{session | dpop_nonce: nonce} 289 + :ok = OAuth.SessionStore.update(session) 290 + session 218 291 end 219 - 220 - @spec auth_error?(body :: Req.Response.t()) :: boolean() 221 - defp auth_error?(%{status: status}) when status in [401, 403], do: true 222 - defp auth_error?(%{body: %{"error" => "InvalidToken"}}), do: true 223 - defp auth_error?(_response), do: false 224 - 225 - @spec put_auth(Req.Request.t(), String.t()) :: Req.Request.t() 226 - defp put_auth(request, token), 227 - do: Req.Request.put_header(request, "authorization", "DPoP #{token}") 228 292 end
+2 -1
mix.exs
··· 41 41 {:jose, "~> 1.11"}, 42 42 {:bandit, "~> 1.0", only: [:dev, :test]}, 43 43 {:con_cache, "~> 1.1"}, 44 - {:mutex, "~> 3.0"} 44 + {:mutex, "~> 3.0"}, 45 + {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false} 45 46 ] 46 47 end 47 48
+2
mix.lock
··· 5 5 "con_cache": {:hex, :con_cache, "1.1.1", "9f47a68dfef5ac3bbff8ce2c499869dbc5ba889dadde6ac4aff8eb78ddaf6d82", [:mix], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1def4d1bec296564c75b5bbc60a19f2b5649d81bfa345a2febcc6ae380e8ae15"}, 6 6 "credo": {:hex, :credo, "1.7.15", "283da72eeb2fd3ccf7248f4941a0527efb97afa224bcdef30b4b580bc8258e1c", [: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", "291e8645ea3fea7481829f1e1eb0881b8395db212821338e577a90bf225c5607"}, 7 7 "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, 8 + "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, 8 9 "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 10 + "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, 9 11 "ex_cldr": {:hex, :ex_cldr, "2.44.1", "0d220b175874e1ce77a0f7213bdfe700b9be11aefbf35933a0e98837803ebdc5", [: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 or ~> 1.0", [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", "3880cd6137ea21c74250cd870d3330c4a9fdec07fabd5e37d1b239547929e29b"}, 10 12 "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"}, 11 13 "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},