+1
AGENTS.md
+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
+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
+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
+2
-1
mix.exs
+2
mix.lock
+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"},