+1
-1
.formatter.exs
+1
-1
.formatter.exs
+5
-1
.gitignore
+5
-1
.gitignore
+13
-1
CHANGELOG.md
+13
-1
CHANGELOG.md
···
6
6
and this project adheres to
7
7
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
9
-
<!-- ## [Unreleased] -->
9
+
## [Unreleased]
10
+
11
+
### Breaking Changes
12
+
13
+
- Remove `Atex.HTTP` and associated modules as the abstraction caused a bit too
14
+
much complexities for how early atex is. It may come back in the future as
15
+
something more fleshed out once we're more stable.
16
+
17
+
### Features
18
+
19
+
- Add `Atex.OAuth` module with utilites for handling some OAuth functionality.
20
+
- Add `Atex.OAuth.Plug` module (if Plug is loaded) which provides a basic but
21
+
complete OAuth flow, including storing the tokens in `Plug.Session`.
10
22
11
23
## [0.4.0] - 2025-08-27
12
24
+10
config/runtime.exs
+10
config/runtime.exs
···
1
+
import Config
2
+
3
+
config :atex, Atex.OAuth,
4
+
# base_url: "https://comet.sh/aaaa",
5
+
base_url: "http://127.0.0.1:4000/oauth",
6
+
is_localhost: true,
7
+
scopes: ~w(transition:generic),
8
+
private_key:
9
+
"MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgyIpxhuDm0i3mPkrk6UdX4Sd9Jsv6YtAmSTza+A2nArShRANCAAQLF1GLueOBZOVnKWfrcnoDOO9NSRqH2utmfGMz+Rce18MDB7Z6CwFWjEq2UFYNBI4MI5cMI0+m+UYAmj4OZm+m",
10
+
key_id: "awooga"
+86
lib/atex/config/oauth.ex
+86
lib/atex/config/oauth.ex
···
1
+
defmodule Atex.Config.OAuth do
2
+
@moduledoc """
3
+
Configuration management for `Atex.OAuth`.
4
+
5
+
Contains all the logic for fetching configuration needed for the OAuth
6
+
module, as well as deriving useful values from them.
7
+
8
+
## Configuration
9
+
10
+
The following structure is expected in your application config:
11
+
12
+
config :atex, Atex.OAuth,
13
+
base_url: "https://example.com/oauth", # Your application's base URL, including the path `Atex.OAuth` is mounted on.
14
+
private_key: "base64-encoded-private-key", # ES256 private key
15
+
key_id: "your-key-id", # Key identifier for JWTs
16
+
scopes: ["transition:generic", "transition:email"], # Optional additional scopes
17
+
extra_redirect_uris: ["https://alternative.com/callback"], # Optional additional redirect URIs
18
+
is_localhost: false # Set to true for local development
19
+
"""
20
+
21
+
@doc """
22
+
Returns the configured public base URL for OAuth routes.
23
+
"""
24
+
@spec base_url() :: String.t()
25
+
def base_url, do: Application.fetch_env!(:atex, Atex.OAuth)[:base_url]
26
+
27
+
@doc """
28
+
Returns the configured private key as a `JOSE.JWK`.
29
+
"""
30
+
@spec get_key() :: JOSE.JWK.t()
31
+
def get_key() do
32
+
private_key =
33
+
Application.fetch_env!(:atex, Atex.OAuth)[:private_key]
34
+
|> Base.decode64!()
35
+
|> JOSE.JWK.from_der()
36
+
37
+
key_id = Application.fetch_env!(:atex, Atex.OAuth)[:key_id]
38
+
39
+
%{private_key | fields: %{"kid" => key_id}}
40
+
end
41
+
42
+
@doc """
43
+
Returns the client ID based on configuration.
44
+
45
+
If `is_localhost` is set, it'll be a string handling the "http://localhost"
46
+
special case, with the redirect URI and scopes configured, otherwise it is a
47
+
string pointing to the location of the `client-metadata.json` route.
48
+
"""
49
+
@spec client_id() :: String.t()
50
+
def client_id() do
51
+
is_localhost = Keyword.get(Application.get_env(:atex, Atex.OAuth, []), :is_localhost, false)
52
+
53
+
if is_localhost do
54
+
query =
55
+
%{redirect_uri: redirect_uri(), scope: scopes()}
56
+
|> URI.encode_query()
57
+
58
+
"http://localhost?#{query}"
59
+
else
60
+
"#{base_url()}/client-metadata.json"
61
+
end
62
+
end
63
+
64
+
@doc """
65
+
Returns the configured redirect URI.
66
+
"""
67
+
@spec redirect_uri() :: String.t()
68
+
def redirect_uri(), do: "#{base_url()}/callback"
69
+
70
+
@doc """
71
+
Returns the configured scopes joined as a space-separated string.
72
+
"""
73
+
@spec scopes() :: String.t()
74
+
def scopes() do
75
+
config_scopes = Keyword.get(Application.get_env(:atex, Atex.OAuth, []), :scopes, [])
76
+
Enum.join(["atproto" | config_scopes], " ")
77
+
end
78
+
79
+
@doc """
80
+
Returns the configured extra redirect URIs.
81
+
"""
82
+
@spec extra_redirect_uris() :: [String.t()]
83
+
def extra_redirect_uris() do
84
+
Keyword.get(Application.get_env(:atex, Atex.OAuth, []), :extra_redirect_uris, [])
85
+
end
86
+
end
+13
lib/atex/identity_resolver/did_document.ex
+13
lib/atex/identity_resolver/did_document.ex
···
125
125
end)
126
126
end
127
127
128
+
@spec get_pds_endpoint(t()) :: String.t() | nil
129
+
def get_pds_endpoint(%__MODULE__{} = doc) do
130
+
doc.service
131
+
|> Enum.find(fn
132
+
%{id: "#atproto_pds", type: "AtprotoPersonalDataServer"} -> true
133
+
_ -> false
134
+
end)
135
+
|> case do
136
+
nil -> nil
137
+
pds -> pds.service_endpoint
138
+
end
139
+
end
140
+
128
141
defp valid_pds_endpoint?(endpoint) do
129
142
case URI.new(endpoint) do
130
143
{:ok, uri} ->
+430
lib/atex/oauth.ex
+430
lib/atex/oauth.ex
···
1
+
defmodule Atex.OAuth do
2
+
@moduledoc """
3
+
OAuth 2.0 implementation for AT Protocol authentication.
4
+
5
+
This module provides utilities for implementing OAuth flows compliant with the
6
+
AT Protocol specification. It includes support for:
7
+
8
+
- Pushed Authorization Requests (PAR)
9
+
- DPoP (Demonstration of Proof of Possession) tokens
10
+
- JWT client assertions
11
+
- PKCE (Proof Key for Code Exchange)
12
+
- Token refresh
13
+
- Handle to PDS resolution
14
+
15
+
## Configuration
16
+
17
+
See `Atex.Config.OAuth` module for configuration documentation.
18
+
19
+
## Usage Example
20
+
21
+
iex> pds = "https://bsky.social"
22
+
iex> login_hint = "example.com"
23
+
iex> {:ok, authz_server} = Atex.OAuth.get_authorization_server(pds)
24
+
iex> {:ok, authz_metadata} = Atex.OAuth.get_authorization_server_metadata(authz_server)
25
+
iex> state = Atex.OAuth.create_nonce()
26
+
iex> code_verifier = Atex.OAuth.create_nonce()
27
+
iex> {:ok, auth_url} = Atex.OAuth.create_authorization_url(
28
+
authz_metadata,
29
+
state,
30
+
code_verifier,
31
+
login_hint
32
+
)
33
+
"""
34
+
35
+
@type authorization_metadata() :: %{
36
+
issuer: String.t(),
37
+
par_endpoint: String.t(),
38
+
token_endpoint: String.t(),
39
+
authorization_endpoint: String.t()
40
+
}
41
+
42
+
@type tokens() :: %{
43
+
access_token: String.t(),
44
+
refresh_token: String.t(),
45
+
did: String.t(),
46
+
expires_at: NaiveDateTime.t()
47
+
}
48
+
49
+
alias Atex.Config.OAuth, as: Config
50
+
51
+
@doc """
52
+
Get a map cnotaining the client metadata information needed for an
53
+
authorization server to validate this client.
54
+
"""
55
+
@spec create_client_metadata() :: map()
56
+
def create_client_metadata() do
57
+
key = Config.get_key()
58
+
{_, jwk} = key |> JOSE.JWK.to_public_map()
59
+
jwk = Map.merge(jwk, %{use: "sig", kid: key.fields["kid"]})
60
+
61
+
# TODO: read more about client-metadata and what specific fields mean to see that we're doing what we actually want to be doing
62
+
63
+
%{
64
+
client_id: Config.client_id(),
65
+
redirect_uris: [Config.redirect_uri() | Config.extra_redirect_uris()],
66
+
application_type: "web",
67
+
grant_types: ["authorization_code", "refresh_token"],
68
+
scope: Config.scopes(),
69
+
response_type: ["code"],
70
+
token_endpoint_auth_method: "private_key_jwt",
71
+
token_endpoint_auth_signing_alg: "ES256",
72
+
dpop_bound_access_tokens: true,
73
+
jwks: %{keys: [jwk]}
74
+
}
75
+
end
76
+
77
+
@doc """
78
+
Retrieves the configured JWT private key for signing client assertions.
79
+
80
+
Loads the private key from configuration, decodes the base64-encoded DER data,
81
+
and creates a JOSE JWK structure with the key ID field set.
82
+
83
+
## Returns
84
+
85
+
A `JOSE.JWK` struct containing the private key and key identifier.
86
+
87
+
## Raises
88
+
89
+
* `Application.Env.Error` if the private_key or key_id configuration is missing
90
+
91
+
## Examples
92
+
93
+
key = OAuth.get_key()
94
+
key = OAuth.get_key()
95
+
"""
96
+
@spec get_key() :: JOSE.JWK.t()
97
+
def get_key(), do: Config.get_key()
98
+
99
+
@doc false
100
+
@spec random_b64(integer()) :: String.t()
101
+
def random_b64(length) do
102
+
:crypto.strong_rand_bytes(length)
103
+
|> Base.url_encode64(padding: false)
104
+
end
105
+
106
+
@doc false
107
+
@spec create_nonce() :: String.t()
108
+
def create_nonce(), do: random_b64(32)
109
+
110
+
@doc """
111
+
Create an OAuth authorization URL for a PDS.
112
+
113
+
Submits a PAR request to the authorization server and constructs the
114
+
authorization URL with the returned request URI. Supports PKCE, DPoP, and
115
+
client assertions as required by the AT Protocol.
116
+
117
+
## Parameters
118
+
119
+
- `authz_metadata` - Authorization server metadata containing endpoints, fetched from `get_authorization_server_metadata/1`
120
+
- `state` - Random token for session validation
121
+
- `code_verifier` - PKCE code verifier
122
+
- `login_hint` - User identifier (handle or DID) for pre-filled login
123
+
124
+
## Returns
125
+
126
+
- `{:ok, authorization_url}` - Successfully created authorization URL
127
+
- `{:ok, :invalid_par_response}` - Server respondend incorrectly to the request
128
+
- `{:error, reason}` - Error creating authorization URL
129
+
"""
130
+
@spec create_authorization_url(
131
+
authorization_metadata(),
132
+
String.t(),
133
+
String.t(),
134
+
String.t()
135
+
) :: {:ok, String.t()} | {:error, any()}
136
+
def create_authorization_url(
137
+
authz_metadata,
138
+
state,
139
+
code_verifier,
140
+
login_hint
141
+
) do
142
+
code_challenge = :crypto.hash(:sha256, code_verifier) |> Base.url_encode64(padding: false)
143
+
key = get_key()
144
+
145
+
# TODO: let keys be optional so no client assertion? is this what results in a confidential client??
146
+
client_assertion =
147
+
create_client_assertion(key, Config.client_id(), authz_metadata.issuer)
148
+
149
+
body =
150
+
%{
151
+
response_type: "code",
152
+
client_id: Config.client_id(),
153
+
redirect_uri: Config.redirect_uri(),
154
+
state: state,
155
+
code_challenge_method: "S256",
156
+
code_challenge: code_challenge,
157
+
scope: Config.scopes(),
158
+
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
159
+
client_assertion: client_assertion,
160
+
login_hint: login_hint
161
+
}
162
+
163
+
case Req.post(authz_metadata.par_endpoint, form: body) do
164
+
{:ok, %{body: %{"request_uri" => request_uri}}} ->
165
+
query =
166
+
%{client_id: Config.client_id(), request_uri: request_uri}
167
+
|> URI.encode_query()
168
+
169
+
{:ok, "#{authz_metadata.authorization_endpoint}?#{query}"}
170
+
171
+
{:ok, _} ->
172
+
{:error, :invalid_par_response}
173
+
174
+
err ->
175
+
err
176
+
end
177
+
end
178
+
179
+
@doc """
180
+
Exchange an OAuth authorization code for a set of access and refresh tokens.
181
+
182
+
Validates the authorization code by submitting it to the token endpoint along with
183
+
the PKCE code verifier and client assertion. Returns access tokens for making authenticated
184
+
requests to the relevant user's PDS.
185
+
186
+
## Parameters
187
+
188
+
- `authz_metadata` - Authorization server metadata containing token endpoint
189
+
- `dpop_key` - JWK for DPoP token generation
190
+
- `code` - Authorization code from OAuth callback
191
+
- `code_verifier` - PKCE code verifier from authorization flow
192
+
193
+
## Returns
194
+
195
+
- `{:ok, tokens, nonce}` - Successfully obtained tokens with returned DPoP nonce
196
+
- `{:error, reason}` - Error exchanging code for tokens
197
+
"""
198
+
@spec validate_authorization_code(
199
+
authorization_metadata(),
200
+
JOSE.JWK.t(),
201
+
String.t(),
202
+
String.t()
203
+
) :: {:ok, tokens(), String.t()} | {:error, any()}
204
+
def validate_authorization_code(
205
+
authz_metadata,
206
+
dpop_key,
207
+
code,
208
+
code_verifier
209
+
) do
210
+
key = get_key()
211
+
212
+
client_assertion =
213
+
create_client_assertion(key, Config.client_id(), authz_metadata.issuer)
214
+
215
+
body =
216
+
%{
217
+
grant_type: "authorization_code",
218
+
client_id: Config.client_id(),
219
+
redirect_uri: Config.redirect_uri(),
220
+
code: code,
221
+
code_verifier: code_verifier,
222
+
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
223
+
client_assertion: client_assertion
224
+
}
225
+
226
+
Req.new(method: :post, url: authz_metadata.token_endpoint, form: body)
227
+
|> send_dpop_request(dpop_key)
228
+
|> case do
229
+
{:ok,
230
+
%{
231
+
"access_token" => access_token,
232
+
"refresh_token" => refresh_token,
233
+
"expires_in" => expires_in,
234
+
"sub" => did
235
+
}, nonce} ->
236
+
expires_at = NaiveDateTime.utc_now() |> NaiveDateTime.add(expires_in, :second)
237
+
238
+
{:ok,
239
+
%{
240
+
access_token: access_token,
241
+
refresh_token: refresh_token,
242
+
did: did,
243
+
expires_at: expires_at
244
+
}, nonce}
245
+
246
+
err ->
247
+
err
248
+
end
249
+
end
250
+
251
+
@doc """
252
+
Fetch the authorization server for a given Personal Data Server (PDS).
253
+
254
+
Makes a request to the PDS's `.well-known/oauth-protected-resource` endpoint
255
+
to discover the associated authorization server that should be used for the
256
+
OAuth flow.
257
+
258
+
## Parameters
259
+
260
+
- `pds_host` - Base URL of the PDS (e.g., "https://bsky.social")
261
+
262
+
## Returns
263
+
264
+
- `{:ok, authorization_server}` - Successfully discovered authorization
265
+
server URL
266
+
- `{:error, :invalid_metadata}` - Server returned invalid metadata
267
+
- `{:error, reason}` - Error discovering authorization server
268
+
"""
269
+
@spec get_authorization_server(String.t()) :: {:ok, String.t()} | {:error, any()}
270
+
def get_authorization_server(pds_host) do
271
+
"#{pds_host}/.well-known/oauth-protected-resource"
272
+
|> Req.get()
273
+
|> case do
274
+
# TODO: what to do when multiple authorization servers?
275
+
{:ok, %{body: %{"authorization_servers" => [authz_server | _]}}} -> {:ok, authz_server}
276
+
{:ok, _} -> {:error, :invalid_metadata}
277
+
err -> err
278
+
end
279
+
end
280
+
281
+
@doc """
282
+
Fetch the metadata for an OAuth authorization server.
283
+
284
+
Retrieves the metadata from the authorization server's
285
+
`.well-known/oauth-authorization-server` endpoint, providing endpoint URLs
286
+
required for the OAuth flow.
287
+
288
+
## Parameters
289
+
290
+
- `issuer` - Authorization server issuer URL
291
+
292
+
## Returns
293
+
294
+
- `{:ok, metadata}` - Successfully retrieved authorization server metadata
295
+
- `{:error, :invalid_metadata}` - Server returned invalid metadata
296
+
- `{:error, :invalid_issuer}` - Issuer mismatch in metadata
297
+
- `{:error, any()}` - Other error fetching metadata
298
+
"""
299
+
@spec get_authorization_server_metadata(String.t()) ::
300
+
{:ok, authorization_metadata()} | {:error, any()}
301
+
def get_authorization_server_metadata(issuer) do
302
+
"#{issuer}/.well-known/oauth-authorization-server"
303
+
|> Req.get()
304
+
|> case do
305
+
{:ok,
306
+
%{
307
+
body: %{
308
+
"issuer" => metadata_issuer,
309
+
"pushed_authorization_request_endpoint" => par_endpoint,
310
+
"token_endpoint" => token_endpoint,
311
+
"authorization_endpoint" => authorization_endpoint
312
+
}
313
+
}} ->
314
+
if issuer != metadata_issuer do
315
+
{:error, :invaild_issuer}
316
+
else
317
+
{:ok,
318
+
%{
319
+
issuer: metadata_issuer,
320
+
par_endpoint: par_endpoint,
321
+
token_endpoint: token_endpoint,
322
+
authorization_endpoint: authorization_endpoint
323
+
}}
324
+
end
325
+
326
+
{:ok, _} ->
327
+
{:error, :invalid_metadata}
328
+
329
+
err ->
330
+
err
331
+
end
332
+
end
333
+
334
+
@spec send_dpop_request(Req.Request.t(), JOSE.JWK.t(), String.t() | nil) ::
335
+
{:ok, map(), String.t()} | {:error, any()}
336
+
defp send_dpop_request(request, dpop_key, nonce \\ nil) do
337
+
dpop_token = create_dpop_token(dpop_key, request, nonce)
338
+
339
+
request
340
+
|> Req.Request.put_header("dpop", dpop_token)
341
+
|> Req.request()
342
+
|> case do
343
+
{:ok, req} ->
344
+
dpop_nonce =
345
+
case req.headers["dpop-nonce"] do
346
+
[new_nonce | _] -> new_nonce
347
+
_ -> nonce
348
+
end
349
+
350
+
cond do
351
+
req.status == 200 ->
352
+
{:ok, req.body, dpop_nonce}
353
+
354
+
req.body["error"] === "use_dpop_nonce" ->
355
+
dpop_token = create_dpop_token(dpop_key, request, dpop_nonce)
356
+
357
+
request
358
+
|> Req.Request.put_header("dpop", dpop_token)
359
+
|> Req.request()
360
+
|> case do
361
+
{:ok, %{status: 200, body: body}} ->
362
+
{:ok, body, dpop_nonce}
363
+
364
+
{:ok, %{body: %{"error" => error, "error_description" => error_description}}} ->
365
+
{:error, {:oauth_error, error, error_description}}
366
+
367
+
{:ok, _} ->
368
+
{:error, :unexpected_response}
369
+
370
+
err ->
371
+
err
372
+
end
373
+
374
+
true ->
375
+
{:error, {:oauth_error, req.body["error"], req.body["error_description"]}}
376
+
end
377
+
378
+
err ->
379
+
err
380
+
end
381
+
end
382
+
383
+
@spec create_client_assertion(JOSE.JWK.t(), String.t(), String.t()) :: String.t()
384
+
defp create_client_assertion(jwk, client_id, issuer) do
385
+
iat = System.os_time(:second)
386
+
jti = random_b64(20)
387
+
jws = %{"alg" => "ES256", "kid" => jwk.fields["kid"]}
388
+
389
+
jwt = %{
390
+
iss: client_id,
391
+
sub: client_id,
392
+
aud: issuer,
393
+
jti: jti,
394
+
iat: iat,
395
+
exp: iat + 60
396
+
}
397
+
398
+
JOSE.JWT.sign(jwk, jws, jwt)
399
+
|> JOSE.JWS.compact()
400
+
|> elem(1)
401
+
end
402
+
403
+
@spec create_dpop_token(JOSE.JWK.t(), Req.Request.t(), any(), map()) :: String.t()
404
+
defp create_dpop_token(jwk, request, nonce \\ nil, attrs \\ %{}) do
405
+
iat = System.os_time(:second)
406
+
jti = random_b64(20)
407
+
{_, public_jwk} = JOSE.JWK.to_public_map(jwk)
408
+
jws = %{"alg" => "ES256", "typ" => "dpop+jwt", "jwk" => public_jwk}
409
+
[request_url | _] = request.url |> to_string() |> String.split("?")
410
+
411
+
jwt =
412
+
Map.merge(attrs, %{
413
+
jti: jti,
414
+
htm: atom_to_upcase_string(request.method),
415
+
htu: request_url,
416
+
iat: iat,
417
+
nonce: nonce
418
+
})
419
+
420
+
JOSE.JWT.sign(jwk, jws, jwt)
421
+
|> JOSE.JWS.compact()
422
+
|> elem(1)
423
+
end
424
+
425
+
@doc false
426
+
@spec atom_to_upcase_string(atom()) :: String.t()
427
+
def atom_to_upcase_string(atom) do
428
+
atom |> to_string() |> String.upcase()
429
+
end
430
+
end
+151
lib/atex/oauth/plug.ex
+151
lib/atex/oauth/plug.ex
···
1
+
if Code.ensure_loaded?(Plug) do
2
+
defmodule Atex.OAuth.Plug do
3
+
@moduledoc """
4
+
Plug router for handling AT Protocol's OAuth flow.
5
+
6
+
This module provides three endpoints:
7
+
8
+
- `GET /login?handle=<handle>` - Initiates the OAuth authorization flow for
9
+
a given handle
10
+
- `GET /callback` - Handles the OAuth callback after user authorization
11
+
- `GET /client-metadata.json` - Serves the OAuth client metadata
12
+
13
+
## Usage
14
+
15
+
This module requires `Plug.Session` to be in your pipeline, as well as
16
+
`secret_key_base` to have been set on your connections. Ideally it should be
17
+
routed to via `Plug.Router.forward/2`, under a route like "/oauth".
18
+
19
+
## Example
20
+
21
+
Example implementation showing how to set up the OAuth plug with proper
22
+
session handling:
23
+
24
+
defmodule ExampleOAuthPlug do
25
+
use Plug.Router
26
+
27
+
plug :put_secret_key_base
28
+
29
+
plug Plug.Session,
30
+
store: :cookie,
31
+
key: "atex-oauth",
32
+
signing_salt: "signing-salt"
33
+
34
+
plug :match
35
+
plug :dispatch
36
+
37
+
forward "/oauth", to: Atex.OAuth.Plug
38
+
39
+
def put_secret_key_base(conn, _) do
40
+
put_in(
41
+
conn.secret_key_base,
42
+
"very long key base with at least 64 bytes"
43
+
)
44
+
end
45
+
end
46
+
47
+
## Session Storage
48
+
49
+
After successful authentication, the plug stores these in the session:
50
+
51
+
* `:tokens` - The access token response containing access_token,
52
+
refresh_token, did, and expires_at
53
+
* `:dpop_key` - The DPoP JWK for generating DPoP proofs
54
+
"""
55
+
require Logger
56
+
use Plug.Router
57
+
require Plug.Router
58
+
alias Atex.OAuth
59
+
alias Atex.{IdentityResolver, IdentityResolver.DIDDocument}
60
+
61
+
@oauth_cookie_opts [path: "/", http_only: true, secure: true, same_site: "lax", max_age: 600]
62
+
63
+
plug :match
64
+
plug :dispatch
65
+
66
+
get "/login" do
67
+
conn = fetch_query_params(conn)
68
+
handle = conn.query_params["handle"]
69
+
70
+
if !handle do
71
+
send_resp(conn, 400, "Need `handle` query parameter")
72
+
else
73
+
case IdentityResolver.resolve(handle) do
74
+
{:ok, identity} ->
75
+
pds = DIDDocument.get_pds_endpoint(identity.document)
76
+
{:ok, authz_server} = OAuth.get_authorization_server(pds)
77
+
{:ok, authz_metadata} = OAuth.get_authorization_server_metadata(authz_server)
78
+
state = OAuth.create_nonce()
79
+
code_verifier = OAuth.create_nonce()
80
+
81
+
case OAuth.create_authorization_url(
82
+
authz_metadata,
83
+
state,
84
+
code_verifier,
85
+
handle
86
+
) do
87
+
{:ok, authz_url} ->
88
+
conn
89
+
|> put_resp_cookie("state", state, @oauth_cookie_opts)
90
+
|> put_resp_cookie("code_verifier", code_verifier, @oauth_cookie_opts)
91
+
|> put_resp_cookie("issuer", authz_metadata.issuer, @oauth_cookie_opts)
92
+
|> put_resp_header("location", authz_url)
93
+
|> send_resp(307, "")
94
+
95
+
err ->
96
+
Logger.error("failed to reate authorization url, #{inspect(err)}")
97
+
send_resp(conn, 500, "Internal server error")
98
+
end
99
+
100
+
{:error, err} ->
101
+
Logger.error("Failed to resolve handle, #{inspect(err)}")
102
+
send_resp(conn, 400, "Invalid handle")
103
+
end
104
+
end
105
+
end
106
+
107
+
get "/client-metadata.json" do
108
+
conn
109
+
|> put_resp_content_type("application/json")
110
+
|> send_resp(200, JSON.encode_to_iodata!(OAuth.create_client_metadata()))
111
+
end
112
+
113
+
get "/callback" do
114
+
conn = conn |> fetch_query_params() |> fetch_session()
115
+
cookies = get_cookies(conn)
116
+
stored_state = cookies["state"]
117
+
stored_code_verifier = cookies["code_verifier"]
118
+
stored_issuer = cookies["issuer"]
119
+
120
+
code = conn.query_params["code"]
121
+
state = conn.query_params["state"]
122
+
123
+
if !stored_state || !stored_code_verifier || !stored_issuer || (!code || !state) ||
124
+
stored_state != state do
125
+
send_resp(conn, 400, "Invalid request")
126
+
else
127
+
with {:ok, authz_metadata} <- OAuth.get_authorization_server_metadata(stored_issuer),
128
+
dpop_key <- JOSE.JWK.generate_key({:ec, "P-256"}),
129
+
{:ok, tokens, nonce} <-
130
+
OAuth.validate_authorization_code(
131
+
authz_metadata,
132
+
dpop_key,
133
+
code,
134
+
stored_code_verifier
135
+
# TODO: verify did pds issuer is the same as stored issuer
136
+
) do
137
+
IO.inspect({tokens, nonce}, label: "OAuth succeeded")
138
+
139
+
conn
140
+
|> put_session(:tokens, tokens)
141
+
|> put_session(:dpop_key, dpop_key)
142
+
|> send_resp(200, "success!! hello #{tokens.did}")
143
+
else
144
+
err ->
145
+
Logger.error("failed to validate oauth callback: #{inspect(err)}")
146
+
send_resp(conn, 500, "Internal server error")
147
+
end
148
+
end
149
+
end
150
+
end
151
+
end
+11
-9
lib/atex/xrpc.ex
+11
-9
lib/atex/xrpc.ex
···
1
1
defmodule Atex.XRPC do
2
-
alias Atex.{HTTP, XRPC}
2
+
alias Atex.XRPC
3
3
4
4
# TODO: automatic user-agent, and env for changing it
5
5
···
12
12
@doc """
13
13
Perform a HTTP GET on a XRPC resource. Called a "query" in lexicons.
14
14
"""
15
-
@spec get(XRPC.Client.t(), String.t(), keyword()) :: HTTP.Adapter.result()
15
+
@spec get(XRPC.Client.t(), String.t(), keyword()) :: {:ok, Req.Response.t()} | {:error, any()}
16
16
def get(%XRPC.Client{} = client, name, opts \\ []) do
17
17
opts = put_auth(opts, client.access_token)
18
-
HTTP.get(url(client, name), opts)
18
+
Req.get(url(client, name), opts)
19
19
end
20
20
21
21
@doc """
22
22
Perform a HTTP POST on a XRPC resource. Called a "prodecure" in lexicons.
23
23
"""
24
-
@spec post(XRPC.Client.t(), String.t(), keyword()) :: HTTP.Adapter.result()
24
+
@spec post(XRPC.Client.t(), String.t(), keyword()) :: {:ok, Req.Response.t()} | {:error, any()}
25
25
def post(%XRPC.Client{} = client, name, opts \\ []) do
26
26
# TODO: look through available HTTP clients and see if they have a
27
27
# consistent way of providing JSON bodies with auto content-type. If not,
28
28
# create one for adapters.
29
29
opts = put_auth(opts, client.access_token)
30
-
HTTP.post(url(client, name), opts)
30
+
Req.post(url(client, name), opts)
31
31
end
32
32
33
33
@doc """
34
34
Like `get/3` but is unauthenticated by default.
35
35
"""
36
-
@spec unauthed_get(String.t(), String.t(), keyword()) :: HTTP.Adapter.result()
36
+
@spec unauthed_get(String.t(), String.t(), keyword()) ::
37
+
{:ok, Req.Response.t()} | {:error, any()}
37
38
def unauthed_get(endpoint, name, opts \\ []) do
38
-
HTTP.get(url(endpoint, name), opts)
39
+
Req.get(url(endpoint, name), opts)
39
40
end
40
41
41
42
@doc """
42
43
Like `post/3` but is unauthenticated by default.
43
44
"""
44
-
@spec unauthed_post(String.t(), String.t(), keyword()) :: HTTP.Adapter.result()
45
+
@spec unauthed_post(String.t(), String.t(), keyword()) ::
46
+
{:ok, Req.Response.t()} | {:error, any()}
45
47
def unauthed_post(endpoint, name, opts \\ []) do
46
-
HTTP.post(url(endpoint, name), opts)
48
+
Req.post(url(endpoint, name), opts)
47
49
end
48
50
49
51
# TODO: use URI module for joining instead?
+3
-3
lib/atex/xrpc/client.ex
+3
-3
lib/atex/xrpc/client.ex
···
39
39
iex> Atex.XRPC.Client.login("https://bsky.social", "example.com", "password123")
40
40
{:ok, %Atex.XRPC.Client{...}}
41
41
"""
42
-
@spec login(String.t(), String.t(), String.t()) :: {:ok, t()} | HTTP.Adapter.error()
42
+
@spec login(String.t(), String.t(), String.t()) :: {:ok, t()} | {:error, any()}
43
43
@spec login(String.t(), String.t(), String.t(), String.t() | nil) ::
44
-
{:ok, t()} | HTTP.Adapter.error()
44
+
{:ok, t()} | {:error, any()}
45
45
def login(endpoint, identifier, password, auth_factor_token \\ nil) do
46
46
json =
47
47
%{identifier: identifier, password: password}
···
67
67
@doc """
68
68
Request a new `refresh_token` for the given client.
69
69
"""
70
-
@spec refresh(t()) :: {:ok, t()} | HTTP.Adapter.error()
70
+
@spec refresh(t()) :: {:ok, t()} | {:error, any()}
71
71
def refresh(%__MODULE__{endpoint: endpoint, refresh_token: refresh_token} = client) do
72
72
response =
73
73
XRPC.unauthed_post(
+1
-1
lib/mix/tasks/atex.lexicons.ex
+1
-1
lib/mix/tasks/atex.lexicons.ex
+4
-1
mix.exs
+4
-1
mix.exs
···
35
35
{:typedstruct, "~> 0.5"},
36
36
{:ex_cldr, "~> 2.42"},
37
37
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
38
-
{:ex_doc, "~> 0.34", only: :dev, runtime: false, warn_if_outdated: true}
38
+
{:ex_doc, "~> 0.34", only: :dev, runtime: false, warn_if_outdated: true},
39
+
{:plug, "~> 1.18", optional: true},
40
+
{:jose, git: "https://github.com/potatosalad/erlang-jose.git", ref: "main"},
41
+
{:bandit, "~> 1.0", only: [:dev, :test]}
39
42
]
40
43
end
41
44
+8
-2
mix.lock
+8
-2
mix.lock
···
1
1
%{
2
+
"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"},
2
3
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
3
4
"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"},
4
5
"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"},
5
6
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
6
7
"earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"},
7
-
"ex_cldr": {:hex, :ex_cldr, "2.42.0", "17ea930e88b8802b330e1c1e288cdbaba52cbfafcccf371ed34b299a47101ffb", [: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", "07264a7225810ecae6bdd6715d8800c037a1248dc0063923cddc4ca3c4888df6"},
8
+
"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"},
8
9
"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"},
9
10
"file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"},
10
11
"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"},
11
12
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
12
13
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
14
+
"jose": {:git, "https://github.com/potatosalad/erlang-jose.git", "e6e6be719695e55618a56416be4d7934dd81deba", [ref: "main"]},
13
15
"makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
14
16
"makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
15
17
"makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"},
···
20
22
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
21
23
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
22
24
"peri": {:hex, :peri, "0.6.1", "6a90ca728a27aef8fef37ce307444255d20364b0c8f8d39e52499d8d825cb514", [:mix], [{:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.1", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "e20ffc659967baf9c4f28799fe7302b656d6662a8b3db7646fdafd017e192743"},
25
+
"plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"},
26
+
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
23
27
"recase": {:hex, :recase, "0.9.0", "437982693fdfbec125f11c8868eb3b4d32e9aa6995d3a68ac8686f3e2bf5d8d1", [:mix], [], "hexpm", "efa7549ebd128988d1723037a6f6a61948055aec107db6288f1c52830cb6501c"},
24
28
"req": {:hex, :req, "0.5.15", "662020efb6ea60b9f0e0fac9be88cd7558b53fe51155a2d9899de594f9906ba9", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "a6513a35fad65467893ced9785457e91693352c70b58bbc045b47e5eb2ef0c53"},
25
29
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
26
-
"typedstruct": {:hex, :typedstruct, "0.5.3", "d68ae424251a41b81a8d0c485328ab48edbd3858f3565bbdac21b43c056fc9b4", [:make, :mix], [], "hexpm", "b53b8186701417c0b2782bf02a2db5524f879b8488f91d1d83b97d84c2943432"},
30
+
"thousand_island": {:hex, :thousand_island, "1.4.1", "8df065e627407e281f7935da5ad0f3842d10eb721afa92e760b720d71e2e37aa", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "204a8640e5d2818589b87286ae66160978628d7edf6095181cbe0440765fb6c1"},
31
+
"typedstruct": {:hex, :typedstruct, "0.5.4", "d1d33d58460a74f413e9c26d55e66fd633abd8ac0fb12639add9a11a60a0462a", [:make, :mix], [], "hexpm", "ffaef36d5dbaebdbf4ed07f7fb2ebd1037b2c1f757db6fb8e7bcbbfabbe608d8"},
27
32
"varint": {:hex, :varint, "1.5.1", "17160c70d0428c3f8a7585e182468cac10bbf165c2360cf2328aaa39d3fb1795", [:mix], [], "hexpm", "24f3deb61e91cb988056de79d06f01161dd01be5e0acae61d8d936a552f1be73"},
33
+
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
28
34
}