···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.
2425- **Error Handling**: Return `{:ok, result}` or `{:error, reason}` tuples; use
2526 pattern matching in case statements
2627- **Pattern Matching**: Prefer pattern matching over conditionals; use guards
+6-2
CHANGELOG.md
···10101111### Breaking Changes
12121313-- Rename `Atex.XRPC.OAuthClient.update_plug/2` to `update_conn/2`, to match the
1414- naming of `from_conn/1`.
1513- `Atex.OAuth.Plug` now raises `Atex.OAuth.Error` exceptions instead of handling
1614 error situations internally. Applications should implement `Plug.ErrorHandler`
1715 to catch and gracefully handle them.
1616+- `Atex.OAuth.Plug` now saves only the user's DID in the session instead of the
1717+ entire OAuth session object. Applications must use `Atex.OAuth.SessionStore`
1818+ to manage OAuth sessions.
1919+- `Atex.XRPC.OAuthClient` has been overhauled to use `Atex.OAuth.SessionStore`
2020+ for retrieving and managing OAuth sessions, making it easier to use with not
2121+ needing to manually keep a Plug session in sync.
18221923### Added
2024
+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_conn(Plug.Conn.t(), t()) :: Plug.Conn.t()
8383- def update_conn(%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