···1111- Fix `raw_input` not actually being set as the request's body in
1212 `Atex.XRPC.post/3` when providing a struct as input.
13131414+### Breaking Changes
1515+1616+- `Atex.OAuth.get_key/0` removed — use `Atex.Config.OAuth.get_key/0` directly
1717+- `Atex.OAuth.create_client_metadata/1`, `create_client_assertion/3`, `create_authorization_url/5`, `validate_authorization_code/5`, `refresh_token/5`, `revoke_tokens/2` moved to `Atex.OAuth.Flow`
1818+- `Atex.OAuth.create_dpop_token/4`, `send_oauth_dpop_request/3`, `request_protected_dpop_resource/5` moved to `Atex.OAuth.DPoP`
1919+- `Atex.OAuth.get_authorization_server/2`, `get_authorization_server_metadata/2` moved to `Atex.OAuth.Discovery`
2020+- Error atom `:invaild_issuer` corrected to `:invalid_issuer`
2121+1422## [0.9.1] - 2026-04-17
15231624### Fixed
+61-776
lib/atex/oauth.ex
···11defmodule Atex.OAuth do
22 @moduledoc """
33- OAuth 2.0 implementation for AT Protocol authentication.
33+ AT Protocol OAuth 2.0 session management.
4455- This module provides utilities for implementing OAuth flows compliant with the
66- AT Protocol specification. It includes support for:
55+ Provides Plug session helpers for managing OAuth sessions in a web application.
66+ For the full OAuth flow, see `Atex.OAuth.Flow`. For authorization server
77+ discovery, see `Atex.OAuth.Discovery`. For DPoP token handling, see
88+ `Atex.OAuth.DPoP`.
7988- - Pushed Authorization Requests (PAR)
99- - DPoP (Demonstration of Proof of Possession) tokens
1010- - JWT client assertions
1111- - PKCE (Proof Key for Code Exchange)
1212- - Token refresh
1313- - Handle to PDS resolution
1010+ ## Type re-exports
14111515- ## Configuration
1212+ The following types are re-exported here for backward compatibility:
16131717- See `Atex.Config.OAuth` module for configuration documentation.
1818-1919- ## Usage Example
2020-2121- iex> pds = "https://bsky.social"
2222- iex> login_hint = "example.com"
2323- iex> {:ok, authz_server} = Atex.OAuth.get_authorization_server(pds)
2424- iex> {:ok, authz_metadata} = Atex.OAuth.get_authorization_server_metadata(authz_server)
2525- iex> state = Atex.OAuth.create_nonce()
2626- iex> code_verifier = Atex.OAuth.create_nonce()
2727- iex> {:ok, auth_url} = Atex.OAuth.create_authorization_url(
2828- authz_metadata,
2929- state,
3030- code_verifier,
3131- login_hint
3232- )
1414+ - `t:Atex.OAuth.Flow.authorization_metadata/0`
1515+ - `t:Atex.OAuth.Flow.tokens/0`
3316 """
34173535- @type authorization_metadata() :: %{
3636- issuer: String.t(),
3737- par_endpoint: String.t(),
3838- token_endpoint: String.t(),
3939- authorization_endpoint: String.t(),
4040- revocation_endpoint: String.t()
4141- }
1818+ alias Atex.OAuth.SessionStore
42194343- @type tokens() :: %{
4444- access_token: String.t(),
4545- refresh_token: String.t(),
4646- did: String.t(),
4747- expires_at: NaiveDateTime.t()
4848- }
4949-5050- @type create_client_metadata_option ::
5151- {:key, JOSE.JWK.t()}
5252- | {:client_id, String.t()}
5353- | {:redirect_uri, String.t()}
5454- | {:extra_redirect_uris, list(String.t())}
5555- | {:scopes, String.t()}
5656-5757- @type create_authorization_url_option ::
5858- {:key, JOSE.JWK.t()}
5959- | {:client_id, String.t()}
6060- | {:redirect_uri, String.t()}
6161- | {:scopes, String.t()}
6262-6363- @type validate_authorization_code_option ::
6464- {:key, JOSE.JWK.t()}
6565- | {:client_id, String.t()}
6666- | {:redirect_uri, String.t()}
6767- | {:scopes, String.t()}
6868-6969- @type refresh_token_option ::
7070- {:key, JOSE.JWK.t()}
7171- | {:client_id, String.t()}
7272- | {:redirect_uri, String.t()}
7373- | {:scopes, String.t()}
7474-7575- require Logger
7676-7777- alias Atex.Config.OAuth, as: Config
7878- alias Atex.OAuth.{Session, SessionStore}
2020+ @type authorization_metadata() :: Atex.OAuth.Flow.authorization_metadata()
2121+ @type tokens() :: Atex.OAuth.Flow.tokens()
79228023 @session_keys_name :atex_sessions
8124 @session_active_name :atex_active_session
82258326 @doc """
8484- Returns the composite session key (`"<did>:<nonce>"`) for the currently active
8585- OAuth session on the given conn.
8686-8787- This is the primary way to identify which session is active for a request. The
8888- returned key can be passed directly to `Atex.OAuth.SessionStore.get/1` or used
8989- to construct an `Atex.XRPC.OAuthClient`.
9090-9191- ## Returns
9292-9393- - `{:ok, session_key}` - The composite key for the active session
9494- - `:error` - No active session found in the conn
2727+ Return the session key atom used to store the list of session keys in a
2828+ `Plug.Conn` session.
95299696- ## Examples
9797-9898- case Atex.OAuth.current_session_key(conn) do
9999- {:ok, key} -> {:ok, client} = Atex.XRPC.OAuthClient.new(key)
100100- :error -> redirect_to_login(conn)
101101- end
102102-3030+ Used by `Atex.OAuth.Plug` when reading and writing session data.
10331 """
104104- @spec current_session_key(Plug.Conn.t()) :: {:ok, String.t()} | :error
105105- def current_session_key(%Plug.Conn{} = conn) do
106106- case Plug.Conn.get_session(conn, @session_active_name) do
107107- key when is_binary(key) -> {:ok, key}
108108- _ -> :error
109109- end
110110- end
3232+ @spec session_keys_name() :: atom()
3333+ def session_keys_name, do: @session_keys_name
1113411235 @doc """
113113- Returns all composite session keys stored for this device's conn session.
3636+ Return the session key atom used to store the active session key in a
3737+ `Plug.Conn` session.
11438115115- Each key corresponds to a distinct authenticated account on this device. The
116116- list is ordered with the most recently logged-in account first.
117117-118118- ## Examples
119119-120120- keys = Atex.OAuth.list_session_keys(conn)
121121- # => ["did:plc:abc:nonce1", "did:plc:xyz:nonce2"]
122122-3939+ Used by `Atex.OAuth.Plug` when reading and writing session data.
12340 """
124124- @spec list_session_keys(Plug.Conn.t()) :: [String.t()]
125125- def list_session_keys(%Plug.Conn{} = conn) do
126126- Plug.Conn.get_session(conn, @session_keys_name) || []
127127- end
4141+ @spec session_active_session_name() :: atom()
4242+ def session_active_session_name, do: @session_active_name
1284312944 @doc """
130130- Switches the active session to the given composite session key.
4545+ Generate a random base64url-encoded nonce suitable for use in OAuth flows.
13146132132- Validates that the key is present in the conn's session list and that the
133133- corresponding session still exists in the store before updating the conn.
134134-135135- ## Returns
136136-137137- - `{:ok, conn}` - Active session switched; the returned conn has the updated
138138- session and should be used for subsequent operations
139139- - `{:error, :not_found}` - The key is not in the session list or the session
140140- no longer exists in the store
4747+ Returns a 32-byte random value encoded as a URL-safe base64 string without
4848+ padding. Useful when building custom authorization flows.
1414914250 ## Examples
14351144144- case Atex.OAuth.switch_session(conn, "did:plc:xyz:nonce2") do
145145- {:ok, conn} -> send_resp(conn, 200, "Switched accounts")
146146- {:error, :not_found} -> send_resp(conn, 404, "Session not found")
147147- end
148148-5252+ iex> nonce = Atex.OAuth.create_nonce()
5353+ iex> is_binary(nonce)
5454+ true
14955 """
150150- @spec switch_session(Plug.Conn.t(), String.t()) :: {:ok, Plug.Conn.t()} | {:error, :not_found}
151151- def switch_session(%Plug.Conn{} = conn, session_key) when is_binary(session_key) do
152152- stored_keys = list_session_keys(conn)
153153-154154- with true <- session_key in stored_keys,
155155- {:ok, _session} <- Atex.OAuth.SessionStore.get(session_key) do
156156- {:ok, Plug.Conn.put_session(conn, @session_active_name, session_key)}
157157- else
158158- _ -> {:error, :not_found}
159159- end
160160- end
161161-162162- @doc """
163163- Get a map containing the client metadata information needed for an
164164- authorization server to validate this client.
165165- """
166166- @spec create_client_metadata(list(create_client_metadata_option())) :: map()
167167- def create_client_metadata(opts \\ []) do
168168- opts =
169169- Keyword.validate!(
170170- opts,
171171- [:key, :client_id, :redirect_uri, :extra_redirect_uris, :scopes]
172172- )
173173-174174- key = Keyword.get_lazy(opts, :key, &Config.get_key/0)
175175- client_id = Keyword.get_lazy(opts, :client_id, &Config.client_id/0)
176176- redirect_uri = Keyword.get_lazy(opts, :redirect_uri, &Config.redirect_uri/0)
177177-178178- extra_redirect_uris =
179179- Keyword.get_lazy(opts, :extra_redirect_uris, &Config.extra_redirect_uris/0)
180180-181181- scopes = Keyword.get_lazy(opts, :scopes, &Config.scopes/0)
182182-183183- {_, jwk} = key |> JOSE.JWK.to_public_map()
184184- jwk = Map.merge(jwk, %{use: "sig", kid: key.fields["kid"]})
185185-186186- %{
187187- client_id: client_id,
188188- redirect_uris: [redirect_uri | extra_redirect_uris],
189189- application_type: "web",
190190- grant_types: ["authorization_code", "refresh_token"],
191191- scope: scopes,
192192- response_type: ["code"],
193193- token_endpoint_auth_method: "private_key_jwt",
194194- token_endpoint_auth_signing_alg: "ES256",
195195- dpop_bound_access_tokens: true,
196196- jwks: %{keys: [jwk]}
197197- }
198198- end
199199-200200- @doc """
201201- Retrieves the configured JWT private key for signing client assertions.
202202-203203- Loads the private key from configuration, decodes the base64-encoded DER data,
204204- and creates a JOSE JWK structure with the key ID field set.
205205-206206- ## Returns
207207-208208- A `JOSE.JWK` struct containing the private key and key identifier.
209209-210210- ## Raises
211211-212212- * `Application.Env.Error` if the private_key or key_id configuration is missing
213213-214214- ## Examples
215215-216216- key = OAuth.get_key()
217217- key = OAuth.get_key()
218218- """
219219- @spec get_key() :: JOSE.JWK.t()
220220- def get_key(), do: Config.get_key()
221221-222222- @doc false
223223- @spec random_b64(integer()) :: String.t()
224224- def random_b64(length) do
225225- :crypto.strong_rand_bytes(length)
226226- |> Base.url_encode64(padding: false)
227227- end
228228-229229- @doc false
23056 @spec create_nonce() :: String.t()
231231- def create_nonce(), do: random_b64(32)
232232-233233- @doc """
234234- Create an OAuth authorization URL for a PDS.
235235-236236- Submits a PAR request to the authorization server and constructs the
237237- authorization URL with the returned request URI. Supports PKCE, DPoP, and
238238- client assertions as required by the AT Protocol.
239239-240240- ## Parameters
241241-242242- - `authz_metadata` - Authorization server metadata containing endpoints, fetched from `get_authorization_server_metadata/1`
243243- - `state` - Random token for session validation
244244- - `code_verifier` - PKCE code verifier
245245- - `login_hint` - User identifier (handle or DID) for pre-filled login
246246-247247- ## Returns
248248-249249- - `{:ok, authorization_url}` - Successfully created authorization URL
250250- - `{:ok, :invalid_par_response}` - Server respondend incorrectly to the request
251251- - `{:error, reason}` - Error creating authorization URL
252252- """
253253- @spec create_authorization_url(
254254- authorization_metadata(),
255255- String.t(),
256256- String.t(),
257257- String.t(),
258258- list(create_authorization_url_option())
259259- ) :: {:ok, String.t()} | {:error, any()}
260260- def create_authorization_url(
261261- authz_metadata,
262262- state,
263263- code_verifier,
264264- login_hint,
265265- opts \\ []
266266- ) do
267267- opts =
268268- Keyword.validate!(
269269- opts,
270270- [:key, :client_id, :redirect_uri, :scopes]
271271- )
272272-273273- key = Keyword.get_lazy(opts, :key, &Config.get_key/0)
274274- client_id = Keyword.get_lazy(opts, :client_id, &Config.client_id/0)
275275- redirect_uri = Keyword.get_lazy(opts, :redirect_uri, &Config.redirect_uri/0)
276276- scopes = Keyword.get_lazy(opts, :scopes, &Config.scopes/0)
277277-278278- code_challenge = :crypto.hash(:sha256, code_verifier) |> Base.url_encode64(padding: false)
279279-280280- client_assertion =
281281- create_client_assertion(key, client_id, authz_metadata.issuer)
282282-283283- body =
284284- %{
285285- response_type: "code",
286286- client_id: client_id,
287287- redirect_uri: redirect_uri,
288288- state: state,
289289- code_challenge_method: "S256",
290290- code_challenge: code_challenge,
291291- scope: scopes,
292292- client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
293293- client_assertion: client_assertion,
294294- login_hint: login_hint
295295- }
296296-297297- case Req.post(authz_metadata.par_endpoint, form: body) do
298298- {:ok, %{body: %{"request_uri" => request_uri}}} ->
299299- query =
300300- %{client_id: client_id, request_uri: request_uri}
301301- |> URI.encode_query()
302302-303303- {:ok, "#{authz_metadata.authorization_endpoint}?#{query}"}
304304-305305- {:ok, _} ->
306306- {:error, :invalid_par_response}
307307-308308- err ->
309309- err
310310- end
5757+ def create_nonce do
5858+ :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false)
31159 end
3126031361 @doc """
314314- Exchange an OAuth authorization code for a set of access and refresh tokens.
6262+ Get the key of the currently active OAuth session from the connection.
31563316316- Validates the authorization code by submitting it to the token endpoint along with
317317- the PKCE code verifier and client assertion. Returns access tokens for making authenticated
318318- requests to the relevant user's PDS.
6464+ Returns `nil` if no session is currently active.
3196532066 ## Parameters
32167322322- - `authz_metadata` - Authorization server metadata containing token endpoint
323323- - `dpop_key` - JWK for DPoP token generation
324324- - `code` - Authorization code from OAuth callback
325325- - `code_verifier` - PKCE code verifier from authorization flow
326326-327327- ## Returns
328328-329329- - `{:ok, tokens, nonce}` - Successfully obtained tokens with returned DPoP nonce
330330- - `{:error, reason}` - Error exchanging code for tokens
6868+ - `conn` - A `Plug.Conn` with session data loaded
33169 """
332332- @spec validate_authorization_code(
333333- authorization_metadata(),
334334- JOSE.JWK.t(),
335335- String.t(),
336336- String.t(),
337337- list(validate_authorization_code_option())
338338- ) :: {:ok, tokens(), String.t()} | {:error, any()}
339339- def validate_authorization_code(
340340- authz_metadata,
341341- dpop_key,
342342- code,
343343- code_verifier,
344344- opts \\ []
345345- ) do
346346- opts =
347347- Keyword.validate!(
348348- opts,
349349- [:key, :client_id, :redirect_uri, :scopes]
350350- )
351351-352352- key = Keyword.get_lazy(opts, :key, &get_key/0)
353353- client_id = Keyword.get_lazy(opts, :client_id, &Config.client_id/0)
354354- redirect_uri = Keyword.get_lazy(opts, :redirect_uri, &Config.redirect_uri/0)
355355-356356- client_assertion =
357357- create_client_assertion(key, client_id, authz_metadata.issuer)
358358-359359- body =
360360- %{
361361- grant_type: "authorization_code",
362362- client_id: client_id,
363363- redirect_uri: redirect_uri,
364364- code: code,
365365- code_verifier: code_verifier
366366- }
367367-368368- body =
369369- if Config.is_localhost(),
370370- do: body,
371371- else:
372372- Map.merge(body, %{
373373- client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
374374- client_assertion: client_assertion
375375- })
376376-377377- Req.new(method: :post, url: authz_metadata.token_endpoint, form: body)
378378- |> send_oauth_dpop_request(dpop_key)
379379- |> case do
380380- {:ok,
381381- %{
382382- "access_token" => access_token,
383383- "refresh_token" => refresh_token,
384384- "expires_in" => expires_in,
385385- "sub" => did
386386- }, nonce} ->
387387- expires_at = NaiveDateTime.utc_now() |> NaiveDateTime.add(expires_in, :second)
388388-389389- {:ok,
390390- %{
391391- access_token: access_token,
392392- refresh_token: refresh_token,
393393- did: did,
394394- expires_at: expires_at
395395- }, nonce}
396396-397397- err ->
398398- err
399399- end
400400- end
401401-402402- @spec refresh_token(
403403- String.t(),
404404- JOSE.JWK.t(),
405405- String.t(),
406406- String.t(),
407407- list(refresh_token_option())
408408- ) ::
409409- {:ok, tokens(), String.t()} | {:error, any()}
410410- def refresh_token(refresh_token, dpop_key, issuer, token_endpoint, opts \\ []) do
411411- opts =
412412- Keyword.validate!(
413413- opts,
414414- [:key, :client_id, :redirect_uri, :scopes]
415415- )
416416-417417- key = Keyword.get_lazy(opts, :key, &get_key/0)
418418- client_id = Keyword.get_lazy(opts, :client_id, &Config.client_id/0)
419419-420420- client_assertion =
421421- create_client_assertion(key, client_id, issuer)
422422-423423- body = %{
424424- grant_type: "refresh_token",
425425- refresh_token: refresh_token,
426426- client_id: client_id,
427427- client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
428428- client_assertion: client_assertion
429429- }
430430-431431- Req.new(method: :post, url: token_endpoint, form: body)
432432- |> send_oauth_dpop_request(dpop_key)
433433- |> case do
434434- {:ok,
435435- %{
436436- "access_token" => access_token,
437437- "refresh_token" => refresh_token,
438438- "expires_in" => expires_in,
439439- "sub" => did
440440- }, nonce} ->
441441- expires_at = NaiveDateTime.utc_now() |> NaiveDateTime.add(expires_in, :second)
442442-443443- {:ok,
444444- %{
445445- access_token: access_token,
446446- refresh_token: refresh_token,
447447- did: did,
448448- expires_at: expires_at
449449- }, nonce}
450450-451451- err ->
452452- err
453453- end
7070+ @spec current_session_key(Plug.Conn.t()) :: String.t() | nil
7171+ def current_session_key(conn) do
7272+ Plug.Conn.get_session(conn, @session_active_name)
45473 end
4557445675 @doc """
457457- Fetch the authorization server for a given Personal Data Server (PDS).
458458-459459- Makes a request to the PDS's `.well-known/oauth-protected-resource` endpoint
460460- to discover the associated authorization server that should be used for the
461461- OAuth flow. Results are cached for 1 hour to reduce load on third-party PDSs.
7676+ List all OAuth session keys stored in the connection's session.
4627746378 ## Parameters
46479465465- - `pds_host` - Base URL of the PDS (e.g., "https://bsky.social")
466466- - `fresh` - If `true`, bypasses the cache and fetches fresh data (default: `false`)
467467-468468- ## Returns
469469-470470- - `{:ok, authorization_server}` - Successfully discovered authorization
471471- server URL
472472- - `{:error, :invalid_metadata}` - Server returned invalid metadata
473473- - `{:error, reason}` - Error discovering authorization server
8080+ - `conn` - A `Plug.Conn` with session data loaded
47481 """
475475- @spec get_authorization_server(String.t(), boolean()) :: {:ok, String.t()} | {:error, any()}
476476- def get_authorization_server(pds_host, fresh \\ false) do
477477- if fresh do
478478- fetch_authorization_server(pds_host)
479479- else
480480- case Atex.OAuth.Cache.get_authorization_server(pds_host) do
481481- {:ok, authz_server} ->
482482- {:ok, authz_server}
483483-484484- {:error, :not_found} ->
485485- fetch_authorization_server(pds_host)
486486- end
487487- end
488488- end
489489-490490- defp fetch_authorization_server(pds_host) do
491491- result =
492492- "#{pds_host}/.well-known/oauth-protected-resource"
493493- |> Req.get()
494494- |> case do
495495- # TODO: what to do when multiple authorization servers?
496496- {:ok, %{body: %{"authorization_servers" => [authz_server | _]}}} -> {:ok, authz_server}
497497- {:ok, _} -> {:error, :invalid_metadata}
498498- err -> err
499499- end
500500-501501- case result do
502502- {:ok, authz_server} ->
503503- Atex.OAuth.Cache.set_authorization_server(pds_host, authz_server)
504504- {:ok, authz_server}
505505-506506- error ->
507507- error
508508- end
509509- end
510510-511511- @doc """
512512- Fetch the metadata for an OAuth authorization server.
513513-514514- Retrieves the metadata from the authorization server's
515515- `.well-known/oauth-authorization-server` endpoint, providing endpoint URLs
516516- required for the OAuth flow. Results are cached for 1 hour to reduce load on
517517- third-party PDSs.
518518-519519- ## Parameters
520520-521521- - `issuer` - Authorization server issuer URL
522522- - `fresh` - If `true`, bypasses the cache and fetches fresh data (default: `false`)
523523-524524- ## Returns
525525-526526- - `{:ok, metadata}` - Successfully retrieved authorization server metadata
527527- - `{:error, :invalid_metadata}` - Server returned invalid metadata
528528- - `{:error, :invalid_issuer}` - Issuer mismatch in metadata
529529- - `{:error, any()}` - Other error fetching metadata
530530- """
531531- @spec get_authorization_server_metadata(String.t(), boolean()) ::
532532- {:ok, authorization_metadata()} | {:error, any()}
533533- def get_authorization_server_metadata(issuer, fresh \\ false) do
534534- if fresh do
535535- fetch_authorization_server_metadata(issuer)
536536- else
537537- case Atex.OAuth.Cache.get_authorization_server_metadata(issuer) do
538538- {:ok, metadata} ->
539539- {:ok, metadata}
540540-541541- {:error, :not_found} ->
542542- fetch_authorization_server_metadata(issuer)
543543- end
544544- end
545545- end
546546-547547- defp fetch_authorization_server_metadata(issuer) do
548548- result =
549549- "#{issuer}/.well-known/oauth-authorization-server"
550550- |> Req.get()
551551- |> case do
552552- {:ok,
553553- %{
554554- body: %{
555555- "issuer" => metadata_issuer,
556556- "pushed_authorization_request_endpoint" => par_endpoint,
557557- "token_endpoint" => token_endpoint,
558558- "authorization_endpoint" => authorization_endpoint,
559559- "revocation_endpoint" => revocation_endpoint
560560- }
561561- }} ->
562562- if issuer != metadata_issuer do
563563- {:error, :invaild_issuer}
564564- else
565565- {:ok,
566566- %{
567567- issuer: metadata_issuer,
568568- par_endpoint: par_endpoint,
569569- token_endpoint: token_endpoint,
570570- authorization_endpoint: authorization_endpoint,
571571- revocation_endpoint: revocation_endpoint
572572- }}
573573- end
574574-575575- {:ok, _} ->
576576- {:error, :invalid_metadata}
577577-578578- err ->
579579- err
580580- end
581581-582582- case result do
583583- {:ok, metadata} ->
584584- Atex.OAuth.Cache.set_authorization_server_metadata(issuer, metadata)
585585- {:ok, metadata}
586586-587587- error ->
588588- error
589589- end
590590- end
591591-592592- @spec send_oauth_dpop_request(Req.Request.t(), JOSE.JWK.t(), String.t() | nil) ::
593593- {:ok, map(), String.t()} | {:error, any(), String.t()}
594594- def send_oauth_dpop_request(request, dpop_key, nonce \\ nil) do
595595- dpop_token = create_dpop_token(dpop_key, request, nonce)
596596-597597- request
598598- |> Req.Request.put_header("dpop", dpop_token)
599599- |> Req.request()
600600- |> case do
601601- {:ok, resp} ->
602602- dpop_nonce =
603603- case resp.headers["dpop-nonce"] do
604604- [new_nonce | _] -> new_nonce
605605- _ -> nonce
606606- end
607607-608608- cond do
609609- resp.status == 200 ->
610610- {:ok, resp.body, dpop_nonce}
611611-612612- resp.body["error"] === "use_dpop_nonce" ->
613613- dpop_token = create_dpop_token(dpop_key, request, dpop_nonce)
614614-615615- request
616616- |> Req.Request.put_header("dpop", dpop_token)
617617- |> Req.request()
618618- |> case do
619619- {:ok, %{status: 200, body: body}} ->
620620- {:ok, body, dpop_nonce}
621621-622622- {:ok, %{body: %{"error" => error, "error_description" => error_description}}} ->
623623- {:error, {:oauth_error, error, error_description}, dpop_nonce}
624624-625625- {:ok, _} ->
626626- {:error, :unexpected_response, dpop_nonce}
627627-628628- {:error, err} ->
629629- {:error, err, dpop_nonce}
630630- end
631631-632632- true ->
633633- {:error, {:oauth_error, resp.body["error"], resp.body["error_description"]},
634634- dpop_nonce}
635635- end
636636-637637- {:error, err} ->
638638- {:error, err, nonce}
639639- end
640640- end
641641-642642- @spec request_protected_dpop_resource(
643643- Req.Request.t(),
644644- String.t(),
645645- String.t(),
646646- JOSE.JWK.t(),
647647- String.t() | nil
648648- ) :: {:ok, Req.Response.t(), String.t() | nil} | {:error, any()}
649649- def request_protected_dpop_resource(request, issuer, access_token, dpop_key, nonce \\ nil) do
650650- access_token_hash = :crypto.hash(:sha256, access_token) |> Base.url_encode64(padding: false)
651651- # access_token_hash = Base.url_encode64(access_token, padding: false)
652652-653653- dpop_token =
654654- create_dpop_token(dpop_key, request, nonce, %{iss: issuer, ath: access_token_hash})
655655-656656- request
657657- |> Req.Request.put_header("dpop", dpop_token)
658658- |> Req.request()
659659- |> case do
660660- {:ok, resp} ->
661661- dpop_nonce =
662662- case resp.headers["dpop-nonce"] do
663663- [new_nonce | _] -> new_nonce
664664- _ -> nonce
665665- end
666666-667667- www_authenticate = Req.Response.get_header(resp, "www-authenticate")
668668-669669- www_dpop_problem =
670670- www_authenticate != [] && String.starts_with?(Enum.at(www_authenticate, 0), "DPoP")
671671-672672- if resp.status != 401 || !www_dpop_problem do
673673- {:ok, resp, dpop_nonce}
674674- else
675675- dpop_token =
676676- create_dpop_token(dpop_key, request, dpop_nonce, %{
677677- iss: issuer,
678678- ath: access_token_hash
679679- })
680680-681681- request
682682- |> Req.Request.put_header("dpop", dpop_token)
683683- |> Req.request()
684684- |> case do
685685- {:ok, resp} ->
686686- dpop_nonce =
687687- case resp.headers["dpop-nonce"] do
688688- [new_nonce | _] -> new_nonce
689689- _ -> dpop_nonce
690690- end
691691-692692- {:ok, resp, dpop_nonce}
693693-694694- err ->
695695- err
696696- end
697697- end
698698- end
8282+ @spec list_session_keys(Plug.Conn.t()) :: list(String.t())
8383+ def list_session_keys(conn) do
8484+ Plug.Conn.get_session(conn, @session_keys_name) || []
69985 end
7008670187 @doc """
702702- Revokes the access and refresh tokens with the authorization server.
8888+ Switch the active OAuth session to the given key.
70389704704- Sends both tokens to the revocation endpoint as defined in RFC 7009.
705705- This invalidates the tokens on the PDS side, preventing further use.
9090+ Updates the `:atex_active_session` value in the Plug session.
7069170792 ## Parameters
70893709709- - `session` - The session containing tokens to revoke
710710- - `authz_metadata` - Authorization server metadata including `revocation_endpoint`
711711-712712- ## Returns
713713-714714- - `:ok` - Tokens successfully revoked (or revocation endpoint unreachable)
715715- - `{:error, reason}` - Revocation failed
716716-9494+ - `conn` - A `Plug.Conn` with session data loaded
9595+ - `session_key` - The session key to make active
71796 """
718718- @spec revoke_tokens(Session.t(), authorization_metadata()) :: :ok | {:error, any()}
719719- def revoke_tokens(%Session{} = session, authz_metadata) do
720720- client_id = Config.client_id()
721721-722722- body = %{
723723- client_id: client_id,
724724- token: session.refresh_token,
725725- token_type_hint: "refresh_token"
726726- }
727727-728728- case Req.post(authz_metadata.revocation_endpoint, form: body) do
729729- {:ok, %{status: status}} when status in [200, 204] ->
730730- :ok
731731-732732- {:ok, %{body: %{"error" => error}}} ->
733733- Logger.warning("Token revocation failed: #{error}")
734734- :ok
735735-736736- {:error, reason} ->
737737- Logger.warning("Token revocation request failed: #{inspect(reason)}")
738738- :ok
739739-740740- unexpected ->
741741- Logger.warning("Unexpected token revocation response: #{inspect(unexpected)}")
742742- :ok
743743- end
9797+ @spec switch_session(Plug.Conn.t(), String.t()) :: Plug.Conn.t()
9898+ def switch_session(conn, session_key) do
9999+ Plug.Conn.put_session(conn, @session_active_name, session_key)
744100 end
745101746102 @doc """
747747- Deletes a session from the store and revokes its tokens.
103103+ Delete the currently active OAuth session.
748104749749- This is the primary function for logging out a session. It:
750750- 1. Fetches the session data from the store if a key is provided
751751- 2. Revokes the tokens with the authorization server
752752- 3. Removes the session from the store
105105+ Removes the active session from `SessionStore`, removes its key from the
106106+ session key list, and clears the active session pointer in the Plug session.
753107754108 ## Parameters
755109756756- - `session_or_key` - Either a `Session.t()` struct or a composite session key string
757757-758758- ## Returns
759759-760760- - `:ok` - Session deleted and tokens revoked
761761- - `{:error, :not_found}` - Session not found in store
762762- - `{:error, reason}` - Token revocation or store deletion failed
763763-764764- ## Examples
765765-766766- # Using a session key
767767- case Atex.OAuth.delete_session("did:plc:abc123:device-nonce") do
768768- :ok -> :logged_out
769769- {:error, :not_found} -> :session_already_gone
770770- end
771771-772772- # Using a session struct
773773- {:ok, session} = Atex.OAuth.SessionStore.get("did:plc:abc123:device-nonce")
774774- :ok = Atex.OAuth.delete_session(session)
775775-110110+ - `conn` - A `Plug.Conn` with session data loaded
776111 """
777777- @spec delete_session(Session.t() | String.t()) :: :ok | {:error, :not_found | any()}
778778- def delete_session(%Session{} = session) do
779779- with {:ok, authz_metadata} <- get_authorization_server_metadata(session.iss, true),
780780- :ok <- revoke_tokens(session, authz_metadata) do
781781- SessionStore.delete(session)
782782- end
783783- end
112112+ @spec delete_session(Plug.Conn.t()) :: Plug.Conn.t()
113113+ def delete_session(conn) do
114114+ session_key = current_session_key(conn)
784115785785- def delete_session(session_key) when is_binary(session_key) do
786786- case SessionStore.get(session_key) do
787787- {:ok, session} -> delete_session(session)
788788- {:error, reason} -> {:error, reason}
116116+ if session_key do
117117+ SessionStore.delete(session_key)
789118 end
790790- end
791119792792- @spec create_client_assertion(JOSE.JWK.t(), String.t(), String.t()) :: String.t()
793793- def create_client_assertion(jwk, client_id, issuer) do
794794- iat = System.os_time(:second)
795795- jti = random_b64(20)
796796- jws = %{"alg" => "ES256", "kid" => jwk.fields["kid"]}
797797-798798- jwt = %{
799799- iss: client_id,
800800- sub: client_id,
801801- aud: issuer,
802802- jti: jti,
803803- iat: iat,
804804- exp: iat + 60
805805- }
806806-807807- JOSE.JWT.sign(jwk, jws, jwt)
808808- |> JOSE.JWS.compact()
809809- |> elem(1)
810810- end
811811-812812- @spec create_dpop_token(JOSE.JWK.t(), Req.Request.t(), any(), map()) :: String.t()
813813- def create_dpop_token(jwk, request, nonce \\ nil, attrs \\ %{}) do
814814- iat = System.os_time(:second)
815815- jti = random_b64(20)
816816- {_, public_jwk} = JOSE.JWK.to_public_map(jwk)
817817- jws = %{"alg" => "ES256", "typ" => "dpop+jwt", "jwk" => public_jwk}
818818- [request_url | _] = request.url |> to_string() |> String.split("?")
819819-820820- jwt =
821821- Map.merge(attrs, %{
822822- jti: jti,
823823- htm: atom_to_upcase_string(request.method),
824824- htu: request_url,
825825- iat: iat
826826- })
827827- |> then(fn m ->
828828- if nonce, do: Map.put(m, :nonce, nonce), else: m
829829- end)
120120+ session_keys = list_session_keys(conn) |> List.delete(session_key)
830121831831- JOSE.JWT.sign(jwk, jws, jwt)
832832- |> JOSE.JWS.compact()
833833- |> elem(1)
834834- end
835835-836836- @doc false
837837- @spec atom_to_upcase_string(atom()) :: String.t()
838838- def atom_to_upcase_string(atom) do
839839- atom |> to_string() |> String.upcase()
122122+ conn
123123+ |> Plug.Conn.put_session(@session_keys_name, session_keys)
124124+ |> Plug.Conn.delete_session(@session_active_name)
840125 end
841126end
+144
lib/atex/oauth/discovery.ex
···11+defmodule Atex.OAuth.Discovery do
22+ @moduledoc """
33+ Authorization server discovery for AT Protocol OAuth.
44+55+ Resolves a PDS to its authorization server and fetches authorization server
66+ metadata. Results are cached for 1 hour via `Atex.OAuth.Cache`.
77+ """
88+99+ alias Atex.OAuth.Cache
1010+1111+ @doc """
1212+ Fetch the authorization server for a given Personal Data Server (PDS).
1313+1414+ Makes a request to the PDS's `.well-known/oauth-protected-resource` endpoint.
1515+ Results are cached for 1 hour to reduce load on third-party PDSs.
1616+1717+ ## Parameters
1818+1919+ - `pds_host` - Base URL of the PDS (e.g., `"https://bsky.social"`)
2020+ - `fresh` - If `true`, bypasses the cache and fetches fresh data (default: `false`)
2121+2222+ ## Returns
2323+2424+ - `{:ok, authorization_server}` - Successfully discovered authorization server URL
2525+ - `{:error, :invalid_metadata}` - Server returned invalid metadata
2626+ - `{:error, reason}` - Error discovering authorization server
2727+ """
2828+ @spec get_authorization_server(String.t(), boolean()) :: {:ok, String.t()} | {:error, any()}
2929+ def get_authorization_server(pds_host, fresh \\ false) do
3030+ if fresh do
3131+ fetch_authorization_server(pds_host)
3232+ else
3333+ case Cache.get_authorization_server(pds_host) do
3434+ {:ok, authz_server} -> {:ok, authz_server}
3535+ {:error, :not_found} -> fetch_authorization_server(pds_host)
3636+ end
3737+ end
3838+ end
3939+4040+ @doc """
4141+ Fetch the metadata for an OAuth authorization server.
4242+4343+ Retrieves the metadata from `.well-known/oauth-authorization-server`.
4444+ Results are cached for 1 hour.
4545+4646+ ## Parameters
4747+4848+ - `issuer` - Authorization server issuer URL
4949+ - `fresh` - If `true`, bypasses the cache and fetches fresh data (default: `false`)
5050+5151+ ## Returns
5252+5353+ - `{:ok, metadata}` - Successfully retrieved authorization server metadata
5454+ - `{:error, :invalid_metadata}` - Server returned invalid metadata
5555+ - `{:error, :invalid_issuer}` - Issuer mismatch in metadata
5656+ - `{:error, any()}` - Other error fetching metadata
5757+ """
5858+ @spec get_authorization_server_metadata(String.t(), boolean()) ::
5959+ {:ok, Atex.OAuth.Flow.authorization_metadata()} | {:error, any()}
6060+ def get_authorization_server_metadata(issuer, fresh \\ false) do
6161+ if fresh do
6262+ fetch_authorization_server_metadata(issuer)
6363+ else
6464+ case Cache.get_authorization_server_metadata(issuer) do
6565+ {:ok, metadata} -> {:ok, metadata}
6666+ {:error, :not_found} -> fetch_authorization_server_metadata(issuer)
6767+ end
6868+ end
6969+ end
7070+7171+ @spec fetch_authorization_server(String.t()) :: {:ok, String.t()} | {:error, any()}
7272+ defp fetch_authorization_server(pds_host) do
7373+ result =
7474+ "#{pds_host}/.well-known/oauth-protected-resource"
7575+ |> Req.get()
7676+ |> case do
7777+ # TODO: what to do when multiple authorization servers?
7878+ {:ok, %{body: %{"authorization_servers" => [authz_server | _]}}} ->
7979+ {:ok, authz_server}
8080+8181+ {:ok, _} ->
8282+ {:error, :invalid_metadata}
8383+8484+ err ->
8585+ err
8686+ end
8787+8888+ case result do
8989+ {:ok, authz_server} ->
9090+ Cache.set_authorization_server(pds_host, authz_server)
9191+ {:ok, authz_server}
9292+9393+ error ->
9494+ error
9595+ end
9696+ end
9797+9898+ @spec fetch_authorization_server_metadata(String.t()) ::
9999+ {:ok, Atex.OAuth.Flow.authorization_metadata()} | {:error, any()}
100100+ defp fetch_authorization_server_metadata(issuer) do
101101+ result =
102102+ "#{issuer}/.well-known/oauth-authorization-server"
103103+ |> Req.get()
104104+ |> case do
105105+ {:ok,
106106+ %{
107107+ body: %{
108108+ "issuer" => metadata_issuer,
109109+ "pushed_authorization_request_endpoint" => par_endpoint,
110110+ "token_endpoint" => token_endpoint,
111111+ "authorization_endpoint" => authorization_endpoint,
112112+ "revocation_endpoint" => revocation_endpoint
113113+ }
114114+ }} ->
115115+ if issuer != metadata_issuer do
116116+ {:error, :invalid_issuer}
117117+ else
118118+ {:ok,
119119+ %{
120120+ issuer: metadata_issuer,
121121+ par_endpoint: par_endpoint,
122122+ token_endpoint: token_endpoint,
123123+ authorization_endpoint: authorization_endpoint,
124124+ revocation_endpoint: revocation_endpoint
125125+ }}
126126+ end
127127+128128+ {:ok, _} ->
129129+ {:error, :invalid_metadata}
130130+131131+ err ->
132132+ err
133133+ end
134134+135135+ case result do
136136+ {:ok, metadata} ->
137137+ Cache.set_authorization_server_metadata(issuer, metadata)
138138+ {:ok, metadata}
139139+140140+ error ->
141141+ error
142142+ end
143143+ end
144144+end
+185
lib/atex/oauth/dpop.ex
···11+defmodule Atex.OAuth.DPoP do
22+ @moduledoc """
33+ DPoP (Demonstrating Proof of Possession) token creation and request handling.
44+55+ Provides functions to create DPoP proof JWTs and send DPoP-protected HTTP
66+ requests, handling the nonce retry dance required by the AT Protocol OAuth
77+ specification.
88+ """
99+1010+ @doc """
1111+ Create a DPoP proof token for a given request.
1212+1313+ Builds a signed JWT containing the HTTP method, URL (without query string),
1414+ a random `jti`, the current timestamp, and an optional server nonce. Extra
1515+ claims (e.g., `iss`, `ath`) can be merged in via `attrs`.
1616+1717+ ## Parameters
1818+1919+ - `jwk` - Private JWK used to sign the proof
2020+ - `request` - The `Req.Request` the token is being produced for
2121+ - `nonce` - Server-provided nonce (optional; omitted from JWT when `nil`)
2222+ - `attrs` - Extra claims to merge into the JWT payload (default: `%{}`)
2323+ """
2424+ @spec create_dpop_token(JOSE.JWK.t(), Req.Request.t(), String.t() | nil, map()) :: String.t()
2525+ def create_dpop_token(jwk, request, nonce \\ nil, attrs \\ %{}) do
2626+ iat = System.os_time(:second)
2727+ jti = random_b64(20)
2828+ {_, public_jwk} = JOSE.JWK.to_public_map(jwk)
2929+ jws = %{"alg" => "ES256", "typ" => "dpop+jwt", "jwk" => public_jwk}
3030+ [request_url | _] = request.url |> to_string() |> String.split("?")
3131+3232+ jwt =
3333+ Map.merge(attrs, %{
3434+ jti: jti,
3535+ htm: request.method |> to_string() |> String.upcase(),
3636+ htu: request_url,
3737+ iat: iat
3838+ })
3939+ |> then(fn m -> if nonce, do: Map.put(m, :nonce, nonce), else: m end)
4040+4141+ JOSE.JWT.sign(jwk, jws, jwt)
4242+ |> JOSE.JWS.compact()
4343+ |> elem(1)
4444+ end
4545+4646+ @doc """
4747+ Send a DPoP-protected request to a token endpoint.
4848+4949+ Attaches a DPoP proof to `request` and sends it. If the server responds with
5050+ `use_dpop_nonce`, retries once with the returned nonce.
5151+5252+ ## Parameters
5353+5454+ - `request` - A `Req.Request` already configured with URL, method, and body
5555+ - `dpop_key` - Private JWK for signing the DPoP proof
5656+ - `nonce` - Current DPoP nonce, if any (default: `nil`)
5757+ """
5858+ @spec send_oauth_dpop_request(Req.Request.t(), JOSE.JWK.t(), String.t() | nil) ::
5959+ {:ok, map(), String.t() | nil} | {:error, any(), String.t() | nil}
6060+ def send_oauth_dpop_request(request, dpop_key, nonce \\ nil) do
6161+ dpop_token = create_dpop_token(dpop_key, request, nonce)
6262+6363+ request
6464+ |> Req.Request.put_header("dpop", dpop_token)
6565+ |> Req.request()
6666+ |> case do
6767+ {:ok, %{status: 200, body: body} = resp} ->
6868+ {:ok, body, extract_nonce(resp, nonce)}
6969+7070+ {:ok, %{body: %{"error" => "use_dpop_nonce"}} = resp} ->
7171+ retry_token_request(request, dpop_key, extract_nonce(resp, nonce))
7272+7373+ {:ok, %{body: %{"error" => error, "error_description" => description}} = resp} ->
7474+ {:error, {:oauth_error, error, description}, extract_nonce(resp, nonce)}
7575+7676+ {:ok, resp} ->
7777+ {:error, :unexpected_response, extract_nonce(resp, nonce)}
7878+7979+ {:error, err} ->
8080+ {:error, err, nonce}
8181+ end
8282+ end
8383+8484+ @doc """
8585+ Send a DPoP-protected request to a resource server (e.g., a PDS endpoint).
8686+8787+ Attaches both the `Authorization: DPoP <token>` header (assumed already set on
8888+ `request`) and a fresh DPoP proof. If the server returns a 401 with a
8989+ `WWW-Authenticate: DPoP ...` header, retries once with the returned nonce.
9090+9191+ ## Parameters
9292+9393+ - `request` - A `Req.Request` with the Authorization header already set
9494+ - `issuer` - Authorization server issuer URL (used in the `iss` claim)
9595+ - `access_token` - The access token (used to compute the `ath` hash claim)
9696+ - `dpop_key` - Private JWK for signing the DPoP proof
9797+ - `nonce` - Current DPoP nonce, if any (default: `nil`)
9898+ """
9999+ @spec request_protected_dpop_resource(
100100+ Req.Request.t(),
101101+ String.t(),
102102+ String.t(),
103103+ JOSE.JWK.t(),
104104+ String.t() | nil
105105+ ) :: {:ok, Req.Response.t(), String.t() | nil} | {:error, any()}
106106+ def request_protected_dpop_resource(request, issuer, access_token, dpop_key, nonce \\ nil) do
107107+ access_token_hash = :crypto.hash(:sha256, access_token) |> Base.url_encode64(padding: false)
108108+ extra_claims = %{iss: issuer, ath: access_token_hash}
109109+ dpop_token = create_dpop_token(dpop_key, request, nonce, extra_claims)
110110+111111+ request
112112+ |> Req.Request.put_header("dpop", dpop_token)
113113+ |> Req.request()
114114+ |> case do
115115+ {:ok, %{status: 401} = resp} ->
116116+ dpop_nonce = extract_nonce(resp, nonce)
117117+118118+ case Req.Response.get_header(resp, "www-authenticate") do
119119+ ["DPoP" <> _ | _] -> retry_resource_request(request, dpop_key, dpop_nonce, extra_claims)
120120+ _ -> {:ok, resp, dpop_nonce}
121121+ end
122122+123123+ {:ok, resp} ->
124124+ {:ok, resp, extract_nonce(resp, nonce)}
125125+126126+ {:error, _} = err ->
127127+ err
128128+ end
129129+ end
130130+131131+ @spec retry_token_request(Req.Request.t(), JOSE.JWK.t(), String.t() | nil) ::
132132+ {:ok, map(), String.t() | nil} | {:error, any(), String.t() | nil}
133133+ defp retry_token_request(request, dpop_key, nonce) do
134134+ dpop_token = create_dpop_token(dpop_key, request, nonce)
135135+136136+ request
137137+ |> Req.Request.put_header("dpop", dpop_token)
138138+ |> Req.request()
139139+ |> case do
140140+ {:ok, %{status: 200, body: body}} ->
141141+ {:ok, body, nonce}
142142+143143+ {:ok, %{body: %{"error" => error, "error_description" => description}}} ->
144144+ {:error, {:oauth_error, error, description}, nonce}
145145+146146+ {:ok, _} ->
147147+ {:error, :unexpected_response, nonce}
148148+149149+ {:error, err} ->
150150+ {:error, err, nonce}
151151+ end
152152+ end
153153+154154+ @spec retry_resource_request(Req.Request.t(), JOSE.JWK.t(), String.t() | nil, map()) ::
155155+ {:ok, Req.Response.t(), String.t() | nil} | {:error, any()}
156156+ defp retry_resource_request(request, dpop_key, nonce, extra_claims) do
157157+ dpop_token = create_dpop_token(dpop_key, request, nonce, extra_claims)
158158+159159+ request
160160+ |> Req.Request.put_header("dpop", dpop_token)
161161+ |> Req.request()
162162+ |> case do
163163+ {:ok, resp} ->
164164+ dpop_nonce = extract_nonce(resp, nonce)
165165+ {:ok, resp, dpop_nonce}
166166+167167+ {:error, _} = err ->
168168+ err
169169+ end
170170+ end
171171+172172+ @spec extract_nonce(Req.Response.t(), String.t() | nil) :: String.t() | nil
173173+ defp extract_nonce(resp, fallback) do
174174+ case resp.headers["dpop-nonce"] do
175175+ [new_nonce | _] -> new_nonce
176176+ _ -> fallback
177177+ end
178178+ end
179179+180180+ @spec random_b64(integer()) :: String.t()
181181+ defp random_b64(length) do
182182+ :crypto.strong_rand_bytes(length)
183183+ |> Base.url_encode64(padding: false)
184184+ end
185185+end
+377
lib/atex/oauth/flow.ex
···11+defmodule Atex.OAuth.Flow do
22+ @moduledoc """
33+ AT Protocol OAuth 2.0 authorization flow.
44+55+ Handles the full OAuth protocol interactions: pushed authorization requests
66+ (PAR), authorization code exchange, token refresh, token revocation, client
77+ metadata, and client assertions.
88+99+ See `Atex.OAuth.Discovery` for authorization server discovery and
1010+ `Atex.OAuth.DPoP` for DPoP token creation.
1111+ """
1212+1313+ require Logger
1414+1515+ alias Atex.Config.OAuth, as: Config
1616+ alias Atex.OAuth.{DPoP, Session}
1717+1818+ @type authorization_metadata() :: %{
1919+ issuer: String.t(),
2020+ par_endpoint: String.t(),
2121+ token_endpoint: String.t(),
2222+ authorization_endpoint: String.t(),
2323+ revocation_endpoint: String.t()
2424+ }
2525+2626+ @type tokens() :: %{
2727+ access_token: String.t(),
2828+ refresh_token: String.t(),
2929+ did: String.t(),
3030+ expires_at: NaiveDateTime.t()
3131+ }
3232+3333+ @type create_client_metadata_option ::
3434+ {:key, JOSE.JWK.t()}
3535+ | {:client_id, String.t()}
3636+ | {:redirect_uri, String.t()}
3737+ | {:extra_redirect_uris, list(String.t())}
3838+ | {:scopes, String.t()}
3939+4040+ @type create_authorization_url_option ::
4141+ {:key, JOSE.JWK.t()}
4242+ | {:client_id, String.t()}
4343+ | {:redirect_uri, String.t()}
4444+ | {:scopes, String.t()}
4545+4646+ @type validate_authorization_code_option ::
4747+ {:key, JOSE.JWK.t()}
4848+ | {:client_id, String.t()}
4949+ | {:redirect_uri, String.t()}
5050+ | {:scopes, String.t()}
5151+5252+ @type refresh_token_option ::
5353+ {:key, JOSE.JWK.t()}
5454+ | {:client_id, String.t()}
5555+5656+ @doc """
5757+ Get a map containing the client metadata information needed for an
5858+ authorization server to validate this client.
5959+ """
6060+ @spec create_client_metadata(list(create_client_metadata_option())) :: map()
6161+ def create_client_metadata(opts \\ []) do
6262+ opts =
6363+ Keyword.validate!(opts, [:key, :client_id, :redirect_uri, :extra_redirect_uris, :scopes])
6464+6565+ key = Keyword.get_lazy(opts, :key, &Config.get_key/0)
6666+ client_id = Keyword.get_lazy(opts, :client_id, &Config.client_id/0)
6767+ redirect_uri = Keyword.get_lazy(opts, :redirect_uri, &Config.redirect_uri/0)
6868+6969+ extra_redirect_uris =
7070+ Keyword.get_lazy(opts, :extra_redirect_uris, &Config.extra_redirect_uris/0)
7171+7272+ scopes = Keyword.get_lazy(opts, :scopes, &Config.scopes/0)
7373+7474+ {_, jwk} = key |> JOSE.JWK.to_public_map()
7575+ jwk = Map.merge(jwk, %{use: "sig", kid: key.fields["kid"]})
7676+7777+ %{
7878+ client_id: client_id,
7979+ redirect_uris: [redirect_uri | extra_redirect_uris],
8080+ application_type: "web",
8181+ grant_types: ["authorization_code", "refresh_token"],
8282+ scope: scopes,
8383+ response_type: ["code"],
8484+ token_endpoint_auth_method: "private_key_jwt",
8585+ token_endpoint_auth_signing_alg: "ES256",
8686+ dpop_bound_access_tokens: true,
8787+ jwks: %{keys: [jwk]}
8888+ }
8989+ end
9090+9191+ @doc """
9292+ Create a JWT client assertion for authenticating with an authorization server.
9393+9494+ Signs a short-lived (60 second) JWT with the client's private key, identifying
9595+ the client to the authorization server.
9696+9797+ ## Parameters
9898+9999+ - `jwk` - Client private key (must have a `kid` field set)
100100+ - `client_id` - OAuth client identifier
101101+ - `issuer` - Authorization server issuer URL (used as `aud`)
102102+ """
103103+ @spec create_client_assertion(JOSE.JWK.t(), String.t(), String.t()) :: String.t()
104104+ def create_client_assertion(jwk, client_id, issuer) do
105105+ iat = System.os_time(:second)
106106+ jti = random_b64(20)
107107+ jws = %{"alg" => "ES256", "kid" => jwk.fields["kid"]}
108108+109109+ jwt = %{
110110+ iss: client_id,
111111+ sub: client_id,
112112+ aud: issuer,
113113+ jti: jti,
114114+ iat: iat,
115115+ exp: iat + 60
116116+ }
117117+118118+ JOSE.JWT.sign(jwk, jws, jwt)
119119+ |> JOSE.JWS.compact()
120120+ |> elem(1)
121121+ end
122122+123123+ @doc """
124124+ Create an OAuth authorization URL for a PDS.
125125+126126+ Submits a PAR request to the authorization server and constructs the
127127+ authorization URL with the returned request URI. Supports PKCE, DPoP, and
128128+ client assertions as required by the AT Protocol.
129129+130130+ ## Parameters
131131+132132+ - `authz_metadata` - Authorization server metadata, from `Atex.OAuth.Discovery.get_authorization_server_metadata/2`
133133+ - `state` - Random token for session validation
134134+ - `code_verifier` - PKCE code verifier
135135+ - `login_hint` - User identifier (handle or DID) for pre-filled login
136136+ - `opts` - Optional overrides for `:key`, `:client_id`, `:redirect_uri`, `:scopes`
137137+138138+ ## Returns
139139+140140+ - `{:ok, authorization_url}` - Successfully created authorization URL
141141+ - `{:error, :invalid_par_response}` - Server responded incorrectly to the PAR request
142142+ - `{:error, reason}` - Error creating authorization URL
143143+ """
144144+ @spec create_authorization_url(
145145+ authorization_metadata(),
146146+ String.t(),
147147+ String.t(),
148148+ String.t(),
149149+ list(create_authorization_url_option())
150150+ ) :: {:ok, String.t()} | {:error, any()}
151151+ def create_authorization_url(authz_metadata, state, code_verifier, login_hint, opts \\ []) do
152152+ opts = Keyword.validate!(opts, [:key, :client_id, :redirect_uri, :scopes])
153153+154154+ key = Keyword.get_lazy(opts, :key, &Config.get_key/0)
155155+ client_id = Keyword.get_lazy(opts, :client_id, &Config.client_id/0)
156156+ redirect_uri = Keyword.get_lazy(opts, :redirect_uri, &Config.redirect_uri/0)
157157+ scopes = Keyword.get_lazy(opts, :scopes, &Config.scopes/0)
158158+159159+ code_challenge = :crypto.hash(:sha256, code_verifier) |> Base.url_encode64(padding: false)
160160+ client_assertion = create_client_assertion(key, client_id, authz_metadata.issuer)
161161+162162+ body = %{
163163+ response_type: "code",
164164+ client_id: client_id,
165165+ redirect_uri: redirect_uri,
166166+ state: state,
167167+ code_challenge_method: "S256",
168168+ code_challenge: code_challenge,
169169+ scope: scopes,
170170+ client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
171171+ client_assertion: client_assertion,
172172+ login_hint: login_hint
173173+ }
174174+175175+ case Req.post(authz_metadata.par_endpoint, form: body) do
176176+ {:ok, %{body: %{"request_uri" => request_uri}}} ->
177177+ query = %{client_id: client_id, request_uri: request_uri} |> URI.encode_query()
178178+ {:ok, "#{authz_metadata.authorization_endpoint}?#{query}"}
179179+180180+ {:ok, _} ->
181181+ {:error, :invalid_par_response}
182182+183183+ err ->
184184+ err
185185+ end
186186+ end
187187+188188+ @doc """
189189+ Exchange an OAuth authorization code for a set of access and refresh tokens.
190190+191191+ Validates the authorization code by submitting it to the token endpoint along
192192+ with the PKCE code verifier and client assertion. Returns access tokens for
193193+ making authenticated requests to the relevant user's PDS.
194194+195195+ ## Parameters
196196+197197+ - `authz_metadata` - Authorization server metadata containing token endpoint
198198+ - `dpop_key` - JWK for DPoP token generation
199199+ - `code` - Authorization code from OAuth callback
200200+ - `code_verifier` - PKCE code verifier from authorization flow
201201+ - `opts` - Optional overrides for `:key`, `:client_id`, `:redirect_uri`, `:scopes`
202202+203203+ ## Returns
204204+205205+ - `{:ok, tokens, nonce}` - Successfully obtained tokens with returned DPoP nonce
206206+ - `{:error, reason}` - Error exchanging code for tokens
207207+ """
208208+ @spec validate_authorization_code(
209209+ authorization_metadata(),
210210+ JOSE.JWK.t(),
211211+ String.t(),
212212+ String.t(),
213213+ list(validate_authorization_code_option())
214214+ ) :: {:ok, tokens(), String.t() | nil} | {:error, any()}
215215+ def validate_authorization_code(authz_metadata, dpop_key, code, code_verifier, opts \\ []) do
216216+ opts = Keyword.validate!(opts, [:key, :client_id, :redirect_uri, :scopes])
217217+218218+ key = Keyword.get_lazy(opts, :key, &Config.get_key/0)
219219+ client_id = Keyword.get_lazy(opts, :client_id, &Config.client_id/0)
220220+ redirect_uri = Keyword.get_lazy(opts, :redirect_uri, &Config.redirect_uri/0)
221221+222222+ client_assertion = create_client_assertion(key, client_id, authz_metadata.issuer)
223223+224224+ body = %{
225225+ grant_type: "authorization_code",
226226+ client_id: client_id,
227227+ redirect_uri: redirect_uri,
228228+ code: code,
229229+ code_verifier: code_verifier
230230+ }
231231+232232+ body =
233233+ if Config.is_localhost(),
234234+ do: body,
235235+ else:
236236+ Map.merge(body, %{
237237+ client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
238238+ client_assertion: client_assertion
239239+ })
240240+241241+ Req.new(method: :post, url: authz_metadata.token_endpoint, form: body)
242242+ |> DPoP.send_oauth_dpop_request(dpop_key)
243243+ |> case do
244244+ {:ok,
245245+ %{
246246+ "access_token" => access_token,
247247+ "refresh_token" => refresh_token,
248248+ "expires_in" => expires_in,
249249+ "sub" => did
250250+ }, nonce} ->
251251+ expires_at = NaiveDateTime.utc_now() |> NaiveDateTime.add(expires_in, :second)
252252+253253+ {:ok,
254254+ %{
255255+ access_token: access_token,
256256+ refresh_token: refresh_token,
257257+ did: did,
258258+ expires_at: expires_at
259259+ }, nonce}
260260+261261+ {:error, reason, _nonce} ->
262262+ {:error, reason}
263263+ end
264264+ end
265265+266266+ @doc """
267267+ Refresh an existing set of OAuth tokens.
268268+269269+ Submits the refresh token to the token endpoint using DPoP authentication and
270270+ a client assertion. Returns the new token set with an updated DPoP nonce.
271271+272272+ ## Parameters
273273+274274+ - `refresh_token` - The refresh token to exchange
275275+ - `dpop_key` - JWK for DPoP token generation
276276+ - `issuer` - Authorization server issuer URL (for client assertion `aud`)
277277+ - `token_endpoint` - Token endpoint URL
278278+ - `opts` - Optional overrides for `:key`, `:client_id`
279279+ """
280280+ @spec refresh_token(
281281+ String.t(),
282282+ JOSE.JWK.t(),
283283+ String.t(),
284284+ String.t(),
285285+ list(refresh_token_option())
286286+ ) :: {:ok, tokens(), String.t() | nil} | {:error, any()}
287287+ def refresh_token(refresh_token, dpop_key, issuer, token_endpoint, opts \\ []) do
288288+ opts = Keyword.validate!(opts, [:key, :client_id])
289289+290290+ key = Keyword.get_lazy(opts, :key, &Config.get_key/0)
291291+ client_id = Keyword.get_lazy(opts, :client_id, &Config.client_id/0)
292292+293293+ client_assertion = create_client_assertion(key, client_id, issuer)
294294+295295+ body = %{
296296+ grant_type: "refresh_token",
297297+ refresh_token: refresh_token,
298298+ client_id: client_id,
299299+ client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
300300+ client_assertion: client_assertion
301301+ }
302302+303303+ Req.new(method: :post, url: token_endpoint, form: body)
304304+ |> DPoP.send_oauth_dpop_request(dpop_key)
305305+ |> case do
306306+ {:ok,
307307+ %{
308308+ "access_token" => access_token,
309309+ "refresh_token" => refresh_token,
310310+ "expires_in" => expires_in,
311311+ "sub" => did
312312+ }, nonce} ->
313313+ expires_at = NaiveDateTime.utc_now() |> NaiveDateTime.add(expires_in, :second)
314314+315315+ {:ok,
316316+ %{
317317+ access_token: access_token,
318318+ refresh_token: refresh_token,
319319+ did: did,
320320+ expires_at: expires_at
321321+ }, nonce}
322322+323323+ {:error, reason, _nonce} ->
324324+ {:error, reason}
325325+ end
326326+ end
327327+328328+ @doc """
329329+ Revokes the access and refresh tokens with the authorization server.
330330+331331+ Sends the refresh token to the revocation endpoint as defined in RFC 7009.
332332+ Token revocation failures are logged as warnings rather than returned as
333333+ errors, since the primary goal (ending the session) is still achieved.
334334+335335+ ## Parameters
336336+337337+ - `session` - The session containing tokens to revoke
338338+ - `authz_metadata` - Authorization server metadata including `revocation_endpoint`
339339+340340+ ## Returns
341341+342342+ - `:ok` - Tokens revoked (or revocation endpoint unreachable - logged, not raised)
343343+ """
344344+ @spec revoke_tokens(Session.t(), authorization_metadata()) :: :ok
345345+ def revoke_tokens(%Session{} = session, authz_metadata) do
346346+ client_id = Config.client_id()
347347+348348+ body = %{
349349+ client_id: client_id,
350350+ token: session.refresh_token,
351351+ token_type_hint: "refresh_token"
352352+ }
353353+354354+ case Req.post(authz_metadata.revocation_endpoint, form: body) do
355355+ {:ok, %{status: status}} when status in [200, 204] ->
356356+ :ok
357357+358358+ {:ok, %{body: %{"error" => error}}} ->
359359+ Logger.warning("Token revocation failed: #{error}")
360360+ :ok
361361+362362+ {:error, reason} ->
363363+ Logger.warning("Token revocation request failed: #{inspect(reason)}")
364364+ :ok
365365+366366+ unexpected ->
367367+ Logger.warning("Unexpected token revocation response: #{inspect(unexpected)}")
368368+ :ok
369369+ end
370370+ end
371371+372372+ @spec random_b64(integer()) :: String.t()
373373+ defp random_b64(length) do
374374+ :crypto.strong_rand_bytes(length)
375375+ |> Base.url_encode64(padding: false)
376376+ end
377377+end
···2626 """
27272828 alias Atex.OAuth
2929+ alias Atex.OAuth.{Discovery, DPoP, Flow}
2930 use TypedStruct
30313132 @behaviour Atex.XRPC.Client
···143144 @spec do_refresh(t()) :: {:ok, OAuth.Session.t()} | {:error, any()}
144145 defp do_refresh(%__MODULE__{session_key: session_key}) do
145146 with {:ok, session} <- OAuth.SessionStore.get(session_key),
146146- {:ok, authz_server} <- OAuth.get_authorization_server(session.aud),
147147+ {:ok, authz_server} <- Discovery.get_authorization_server(session.aud),
147148 {:ok, %{token_endpoint: token_endpoint}} <-
148148- OAuth.get_authorization_server_metadata(authz_server) do
149149- case OAuth.refresh_token(
149149+ Discovery.get_authorization_server_metadata(authz_server) do
150150+ case Flow.refresh_token(
150151 session.refresh_token,
151152 session.dpop_key,
152153 session.iss,
···227228 |> Req.new()
228229 |> Req.Request.put_header("authorization", "DPoP #{session.access_token}")
229230230230- case OAuth.request_protected_dpop_resource(
231231+ case DPoP.request_protected_dpop_resource(
231232 request,
232233 session.iss,
233234 session.access_token,
···264265 if auth_error?(response) do
265266 case do_refresh(client) do
266267 {:ok, session} ->
267267- case OAuth.request_protected_dpop_resource(
268268+ case DPoP.request_protected_dpop_resource(
268269 request,
269270 session.iss,
270271 session.access_token,
+94
test/atex/oauth/dpop_test.exs
···11+defmodule Atex.OAuth.DPoPTest do
22+ use ExUnit.Case, async: true
33+44+ alias Atex.OAuth.DPoP
55+66+ describe "create_dpop_token/4" do
77+ test "returns a compact JWT string" do
88+ key = JOSE.JWK.generate_key({:ec, "P-256"})
99+ request = Req.new(method: :get, url: "https://example.com/xrpc/foo")
1010+1111+ token = DPoP.create_dpop_token(key, request)
1212+1313+ assert is_binary(token)
1414+ assert length(String.split(token, ".")) == 3
1515+ end
1616+1717+ test "sets htm to uppercased HTTP method" do
1818+ key = JOSE.JWK.generate_key({:ec, "P-256"})
1919+ request = Req.new(method: :post, url: "https://example.com/xrpc/foo")
2020+2121+ token = DPoP.create_dpop_token(key, request)
2222+ %{fields: claims} = JOSE.JWT.peek(token)
2323+2424+ assert claims["htm"] == "POST"
2525+ end
2626+2727+ test "sets htu to URL without query string" do
2828+ key = JOSE.JWK.generate_key({:ec, "P-256"})
2929+ request = Req.new(method: :get, url: "https://example.com/xrpc/foo?bar=baz")
3030+3131+ token = DPoP.create_dpop_token(key, request)
3232+ %{fields: claims} = JOSE.JWT.peek(token)
3333+3434+ assert claims["htu"] == "https://example.com/xrpc/foo"
3535+ end
3636+3737+ test "includes nonce claim when provided" do
3838+ key = JOSE.JWK.generate_key({:ec, "P-256"})
3939+ request = Req.new(method: :get, url: "https://example.com/xrpc/foo")
4040+4141+ token = DPoP.create_dpop_token(key, request, "my-server-nonce")
4242+ %{fields: claims} = JOSE.JWT.peek(token)
4343+4444+ assert claims["nonce"] == "my-server-nonce"
4545+ end
4646+4747+ test "omits nonce claim when nil" do
4848+ key = JOSE.JWK.generate_key({:ec, "P-256"})
4949+ request = Req.new(method: :get, url: "https://example.com/xrpc/foo")
5050+5151+ token = DPoP.create_dpop_token(key, request, nil)
5252+ %{fields: claims} = JOSE.JWT.peek(token)
5353+5454+ refute Map.has_key?(claims, "nonce")
5555+ end
5656+5757+ test "merges extra claims into the JWT" do
5858+ key = JOSE.JWK.generate_key({:ec, "P-256"})
5959+ request = Req.new(method: :get, url: "https://example.com/xrpc/foo")
6060+6161+ token =
6262+ DPoP.create_dpop_token(key, request, nil, %{iss: "https://bsky.social", ath: "abc123"})
6363+6464+ %{fields: claims} = JOSE.JWT.peek(token)
6565+6666+ assert claims["iss"] == "https://bsky.social"
6767+ assert claims["ath"] == "abc123"
6868+ end
6969+7070+ test "sets jti and iat" do
7171+ key = JOSE.JWK.generate_key({:ec, "P-256"})
7272+ request = Req.new(method: :get, url: "https://example.com/xrpc/foo")
7373+7474+ token = DPoP.create_dpop_token(key, request)
7575+ %{fields: claims} = JOSE.JWT.peek(token)
7676+7777+ assert is_binary(claims["jti"])
7878+ assert String.length(claims["jti"]) > 0
7979+ assert is_integer(claims["iat"])
8080+ end
8181+8282+ test "generates unique jti per call" do
8383+ key = JOSE.JWK.generate_key({:ec, "P-256"})
8484+ request = Req.new(method: :get, url: "https://example.com/xrpc/foo")
8585+8686+ token1 = DPoP.create_dpop_token(key, request)
8787+ token2 = DPoP.create_dpop_token(key, request)
8888+ %{fields: claims1} = JOSE.JWT.peek(token1)
8989+ %{fields: claims2} = JOSE.JWT.peek(token2)
9090+9191+ refute claims1["jti"] == claims2["jti"]
9292+ end
9393+ end
9494+end
+84
test/atex/oauth/flow_test.exs
···11+defmodule Atex.OAuth.FlowTest do
22+ use ExUnit.Case, async: true
33+44+ alias Atex.OAuth.Flow
55+66+ describe "create_client_assertion/3" do
77+ setup do
88+ key = JOSE.JWK.generate_key({:ec, "P-256"})
99+ key = %{key | fields: Map.put(key.fields, "kid", "test-kid-123")}
1010+ %{key: key}
1111+ end
1212+1313+ test "returns a compact JWT string", %{key: key} do
1414+ token =
1515+ Flow.create_client_assertion(
1616+ key,
1717+ "https://example.com/client-metadata.json",
1818+ "https://bsky.social"
1919+ )
2020+2121+ assert is_binary(token)
2222+ assert length(String.split(token, ".")) == 3
2323+ end
2424+2525+ test "sets iss and sub to client_id", %{key: key} do
2626+ client_id = "https://example.com/client-metadata.json"
2727+ token = Flow.create_client_assertion(key, client_id, "https://bsky.social")
2828+2929+ %{fields: claims} = JOSE.JWT.peek(token)
3030+3131+ assert claims["iss"] == client_id
3232+ assert claims["sub"] == client_id
3333+ end
3434+3535+ test "sets aud to issuer", %{key: key} do
3636+ issuer = "https://bsky.social"
3737+3838+ token =
3939+ Flow.create_client_assertion(key, "https://example.com/client-metadata.json", issuer)
4040+4141+ %{fields: claims} = JOSE.JWT.peek(token)
4242+4343+ assert claims["aud"] == issuer
4444+ end
4545+4646+ test "expires 60 seconds after iat", %{key: key} do
4747+ token =
4848+ Flow.create_client_assertion(
4949+ key,
5050+ "https://example.com/client-metadata.json",
5151+ "https://bsky.social"
5252+ )
5353+5454+ %{fields: claims} = JOSE.JWT.peek(token)
5555+5656+ assert claims["exp"] - claims["iat"] == 60
5757+ end
5858+5959+ test "sets a non-empty jti", %{key: key} do
6060+ token =
6161+ Flow.create_client_assertion(
6262+ key,
6363+ "https://example.com/client-metadata.json",
6464+ "https://bsky.social"
6565+ )
6666+6767+ %{fields: claims} = JOSE.JWT.peek(token)
6868+6969+ assert is_binary(claims["jti"])
7070+ assert String.length(claims["jti"]) > 0
7171+ end
7272+7373+ test "produces a validly signed JWT", %{key: key} do
7474+ token =
7575+ Flow.create_client_assertion(
7676+ key,
7777+ "https://example.com/client-metadata.json",
7878+ "https://bsky.social"
7979+ )
8080+8181+ {true, %JOSE.JWT{}, _} = JOSE.JWT.verify(JOSE.JWK.to_public(key), token)
8282+ end
8383+ end
8484+end
+27
test/atex/oauth_test.exs
···11+defmodule Atex.OAuthTest do
22+ use ExUnit.Case, async: true
33+44+ alias Atex.OAuth
55+66+ describe "create_nonce/0" do
77+ test "returns a binary" do
88+ assert is_binary(OAuth.create_nonce())
99+ end
1010+1111+ test "returns unique values on each call" do
1212+ refute OAuth.create_nonce() == OAuth.create_nonce()
1313+ end
1414+ end
1515+1616+ describe "session_keys_name/0" do
1717+ test "returns the session keys atom" do
1818+ assert OAuth.session_keys_name() == :atex_sessions
1919+ end
2020+ end
2121+2222+ describe "session_active_session_name/0" do
2323+ test "returns the active session atom" do
2424+ assert OAuth.session_active_session_name() == :atex_active_session
2525+ end
2626+ end
2727+end