···1515 something more fleshed out once we're more stable.
1616- Rename `Atex.XRPC.Client` to `Atex.XRPC.LoginClient`
17171818-### Features
1818+### Added
19192020-- Add `Atex.OAuth` module with utilites for handling some OAuth functionality.
2121-- Add `Atex.OAuth.Plug` module (if Plug is loaded) which provides a basic but
2020+- `Atex.OAuth` module with utilites for handling some OAuth functionality.
2121+- `Atex.OAuth.Plug` module (if Plug is loaded) which provides a basic but
2222 complete OAuth flow, including storing the tokens in `Plug.Session`.
2323-- Add `Atex.XRPC.Client` behaviour for implementing custom client variants.
2424-- `Atex.XRPC` now delegates get/post options to the provided client struct.
2323+- `Atex.XRPC.Client` behaviour for implementing custom client variants.
2424+- `Atex.XRPC` now supports using different client implementations.
2525+- `Atex.XRPC.OAuthClient` to make XRPC calls on the behalf of a user who has
2626+ authenticated with ATProto OAuth.
25272628## [0.4.0] - 2025-08-27
2729
+1-1
README.md
···1111- [x] DID & handle resolution service with a cache
1212- [x] Macro for converting a Lexicon definition into a runtime-validation schema
1313 - [x] Codegen to convert a directory of lexicons
1414+- [x] Oauth stuff
1415- [ ] Extended XRPC client with support for validated inputs/outputs
1515-- [ ] Oauth stuff
16161717## Installation
1818
···11-if Code.ensure_loaded?(Plug) do
22- defmodule Atex.OAuth.Plug do
33- @moduledoc """
44- Plug router for handling AT Protocol's OAuth flow.
11+defmodule Atex.OAuth.Plug do
22+ @moduledoc """
33+ Plug router for handling AT Protocol's OAuth flow.
5466- This module provides three endpoints:
55+ This module provides three endpoints:
7688- - `GET /login?handle=<handle>` - Initiates the OAuth authorization flow for
99- a given handle
1010- - `GET /callback` - Handles the OAuth callback after user authorization
1111- - `GET /client-metadata.json` - Serves the OAuth client metadata
77+ - `GET /login?handle=<handle>` - Initiates the OAuth authorization flow for
88+ a given handle
99+ - `GET /callback` - Handles the OAuth callback after user authorization
1010+ - `GET /client-metadata.json` - Serves the OAuth client metadata
12111313- ## Usage
1212+ ## Usage
14131515- This module requires `Plug.Session` to be in your pipeline, as well as
1616- `secret_key_base` to have been set on your connections. Ideally it should be
1717- routed to via `Plug.Router.forward/2`, under a route like "/oauth".
1414+ This module requires `Plug.Session` to be in your pipeline, as well as
1515+ `secret_key_base` to have been set on your connections. Ideally it should be
1616+ routed to via `Plug.Router.forward/2`, under a route like "/oauth".
18171919- ## Example
1818+ ## Example
20192121- Example implementation showing how to set up the OAuth plug with proper
2222- session handling:
2020+ Example implementation showing how to set up the OAuth plug with proper
2121+ session handling:
23222424- defmodule ExampleOAuthPlug do
2525- use Plug.Router
2323+ defmodule ExampleOAuthPlug do
2424+ use Plug.Router
26252727- plug :put_secret_key_base
2626+ plug :put_secret_key_base
28272929- plug Plug.Session,
3030- store: :cookie,
3131- key: "atex-oauth",
3232- signing_salt: "signing-salt"
2828+ plug Plug.Session,
2929+ store: :cookie,
3030+ key: "atex-oauth",
3131+ signing_salt: "signing-salt"
33323434- plug :match
3535- plug :dispatch
3333+ plug :match
3434+ plug :dispatch
36353737- forward "/oauth", to: Atex.OAuth.Plug
3636+ forward "/oauth", to: Atex.OAuth.Plug
38373939- def put_secret_key_base(conn, _) do
4040- put_in(
4141- conn.secret_key_base,
4242- "very long key base with at least 64 bytes"
4343- )
4444- end
3838+ def put_secret_key_base(conn, _) do
3939+ put_in(
4040+ conn.secret_key_base,
4141+ "very long key base with at least 64 bytes"
4242+ )
4543 end
4444+ end
46454747- ## Session Storage
4646+ ## Session Storage
48474949- After successful authentication, the plug stores these in the session:
4848+ After successful authentication, the plug stores these in the session:
50495151- * `:tokens` - The access token response containing access_token,
5252- refresh_token, did, and expires_at
5353- * `:dpop_key` - The DPoP JWK for generating DPoP proofs
5454- """
5555- require Logger
5656- use Plug.Router
5757- require Plug.Router
5858- alias Atex.OAuth
5959- alias Atex.{IdentityResolver, IdentityResolver.DIDDocument}
5050+ - `:tokens` - The access token response containing access_token,
5151+ refresh_token, did, and expires_at
5252+ - `:dpop_nonce` -
5353+ - `:dpop_key` - The DPoP JWK for generating DPoP proofs
5454+ """
5555+ require Logger
5656+ use Plug.Router
5757+ require Plug.Router
5858+ alias Atex.OAuth
5959+ alias Atex.{IdentityResolver, IdentityResolver.DIDDocument}
60606161- @oauth_cookie_opts [path: "/", http_only: true, secure: true, same_site: "lax", max_age: 600]
6161+ @oauth_cookie_opts [path: "/", http_only: true, secure: true, same_site: "lax", max_age: 600]
62626363- plug :match
6464- plug :dispatch
6363+ plug :match
6464+ plug :dispatch
65656666- get "/login" do
6767- conn = fetch_query_params(conn)
6868- handle = conn.query_params["handle"]
6666+ get "/login" do
6767+ conn = fetch_query_params(conn)
6868+ handle = conn.query_params["handle"]
69697070- if !handle do
7171- send_resp(conn, 400, "Need `handle` query parameter")
7272- else
7373- case IdentityResolver.resolve(handle) do
7474- {:ok, identity} ->
7575- pds = DIDDocument.get_pds_endpoint(identity.document)
7676- {:ok, authz_server} = OAuth.get_authorization_server(pds)
7777- {:ok, authz_metadata} = OAuth.get_authorization_server_metadata(authz_server)
7878- state = OAuth.create_nonce()
7979- code_verifier = OAuth.create_nonce()
7070+ if !handle do
7171+ send_resp(conn, 400, "Need `handle` query parameter")
7272+ else
7373+ case IdentityResolver.resolve(handle) do
7474+ {:ok, identity} ->
7575+ pds = DIDDocument.get_pds_endpoint(identity.document)
7676+ {:ok, authz_server} = OAuth.get_authorization_server(pds)
7777+ {:ok, authz_metadata} = OAuth.get_authorization_server_metadata(authz_server)
7878+ state = OAuth.create_nonce()
7979+ code_verifier = OAuth.create_nonce()
80808181- case OAuth.create_authorization_url(
8282- authz_metadata,
8383- state,
8484- code_verifier,
8585- handle
8686- ) do
8787- {:ok, authz_url} ->
8888- conn
8989- |> put_resp_cookie("state", state, @oauth_cookie_opts)
9090- |> put_resp_cookie("code_verifier", code_verifier, @oauth_cookie_opts)
9191- |> put_resp_cookie("issuer", authz_metadata.issuer, @oauth_cookie_opts)
9292- |> put_resp_header("location", authz_url)
9393- |> send_resp(307, "")
8181+ case OAuth.create_authorization_url(
8282+ authz_metadata,
8383+ state,
8484+ code_verifier,
8585+ handle
8686+ ) do
8787+ {:ok, authz_url} ->
8888+ conn
8989+ |> put_resp_cookie("state", state, @oauth_cookie_opts)
9090+ |> put_resp_cookie("code_verifier", code_verifier, @oauth_cookie_opts)
9191+ |> put_resp_cookie("issuer", authz_metadata.issuer, @oauth_cookie_opts)
9292+ |> put_resp_header("location", authz_url)
9393+ |> send_resp(307, "")
94949595- err ->
9696- Logger.error("failed to reate authorization url, #{inspect(err)}")
9797- send_resp(conn, 500, "Internal server error")
9898- end
9595+ err ->
9696+ Logger.error("failed to reate authorization url, #{inspect(err)}")
9797+ send_resp(conn, 500, "Internal server error")
9898+ end
9999100100- {:error, err} ->
101101- Logger.error("Failed to resolve handle, #{inspect(err)}")
102102- send_resp(conn, 400, "Invalid handle")
103103- end
100100+ {:error, err} ->
101101+ Logger.error("Failed to resolve handle, #{inspect(err)}")
102102+ send_resp(conn, 400, "Invalid handle")
104103 end
105104 end
105105+ end
106106107107- get "/client-metadata.json" do
108108- conn
109109- |> put_resp_content_type("application/json")
110110- |> send_resp(200, JSON.encode_to_iodata!(OAuth.create_client_metadata()))
111111- end
107107+ get "/client-metadata.json" do
108108+ conn
109109+ |> put_resp_content_type("application/json")
110110+ |> send_resp(200, JSON.encode_to_iodata!(OAuth.create_client_metadata()))
111111+ end
112112113113- get "/callback" do
114114- conn = conn |> fetch_query_params() |> fetch_session()
115115- cookies = get_cookies(conn)
116116- stored_state = cookies["state"]
117117- stored_code_verifier = cookies["code_verifier"]
118118- stored_issuer = cookies["issuer"]
113113+ get "/callback" do
114114+ conn = conn |> fetch_query_params() |> fetch_session()
115115+ cookies = get_cookies(conn)
116116+ stored_state = cookies["state"]
117117+ stored_code_verifier = cookies["code_verifier"]
118118+ stored_issuer = cookies["issuer"]
119119120120- code = conn.query_params["code"]
121121- state = conn.query_params["state"]
120120+ code = conn.query_params["code"]
121121+ state = conn.query_params["state"]
122122123123- if !stored_state || !stored_code_verifier || !stored_issuer || (!code || !state) ||
124124- stored_state != state do
125125- send_resp(conn, 400, "Invalid request")
123123+ if !stored_state || !stored_code_verifier || !stored_issuer || (!code || !state) ||
124124+ stored_state != state do
125125+ send_resp(conn, 400, "Invalid request")
126126+ else
127127+ with {:ok, authz_metadata} <- OAuth.get_authorization_server_metadata(stored_issuer),
128128+ dpop_key <- JOSE.JWK.generate_key({:ec, "P-256"}),
129129+ {:ok, tokens, nonce} <-
130130+ OAuth.validate_authorization_code(
131131+ authz_metadata,
132132+ dpop_key,
133133+ code,
134134+ stored_code_verifier
135135+ ),
136136+ {:ok, identity} <- IdentityResolver.resolve(tokens.did),
137137+ # Make sure pds' issuer matches the stored one (just in case)
138138+ pds <- DIDDocument.get_pds_endpoint(identity.document),
139139+ {:ok, authz_server} <- OAuth.get_authorization_server(pds),
140140+ true <- authz_server == stored_issuer do
141141+ conn
142142+ |> delete_resp_cookie("state", @oauth_cookie_opts)
143143+ |> delete_resp_cookie("code_verifier", @oauth_cookie_opts)
144144+ |> delete_resp_cookie("issuer", @oauth_cookie_opts)
145145+ |> put_session(:atex_oauth, %{
146146+ access_token: tokens.access_token,
147147+ refresh_token: tokens.refresh_token,
148148+ did: tokens.did,
149149+ pds: pds,
150150+ expires_at: tokens.expires_at,
151151+ dpop_nonce: nonce,
152152+ dpop_key: dpop_key
153153+ })
154154+ |> send_resp(200, "success!! hello #{tokens.did}")
126155 else
127127- with {:ok, authz_metadata} <- OAuth.get_authorization_server_metadata(stored_issuer),
128128- dpop_key <- JOSE.JWK.generate_key({:ec, "P-256"}),
129129- {:ok, tokens, nonce} <-
130130- OAuth.validate_authorization_code(
131131- authz_metadata,
132132- dpop_key,
133133- code,
134134- stored_code_verifier
135135- # TODO: verify did pds issuer is the same as stored issuer
136136- ) do
137137- IO.inspect({tokens, nonce}, label: "OAuth succeeded")
156156+ false ->
157157+ send_resp(conn, 400, "OAuth issuer does not match your PDS' authorization server")
138158139139- conn
140140- |> put_session(:tokens, tokens)
141141- |> put_session(:dpop_key, dpop_key)
142142- |> send_resp(200, "success!! hello #{tokens.did}")
143143- else
144144- err ->
145145- Logger.error("failed to validate oauth callback: #{inspect(err)}")
146146- send_resp(conn, 500, "Internal server error")
147147- end
159159+ err ->
160160+ Logger.error("failed to validate oauth callback: #{inspect(err)}")
161161+ send_resp(conn, 500, "Internal server error")
148162 end
149163 end
150164 end
-1
lib/atex/xrpc.ex
···6868 Req.post(url(endpoint, name), opts)
6969 end
70707171- # TODO: use URI module for joining instead?
7271 @doc """
7372 Create an XRPC url based on an endpoint and a resource name.
7473
+228
lib/atex/xrpc/oauth_client.ex
···11+defmodule Atex.XRPC.OAuthClient do
22+ alias Atex.OAuth
33+ alias Atex.XRPC
44+ use TypedStruct
55+66+ @behaviour Atex.XRPC.Client
77+88+ typedstruct enforce: true do
99+ field :endpoint, String.t()
1010+ field :issuer, String.t()
1111+ field :access_token, String.t()
1212+ field :refresh_token, String.t()
1313+ field :did, String.t()
1414+ field :expires_at, NaiveDateTime.t()
1515+ field :dpop_nonce, String.t() | nil, enforce: false
1616+ field :dpop_key, JOSE.JWK.t()
1717+ end
1818+1919+ @doc """
2020+ Create a new OAuthClient struct.
2121+ """
2222+ @spec new(
2323+ String.t(),
2424+ String.t(),
2525+ String.t(),
2626+ String.t(),
2727+ NaiveDateTime.t(),
2828+ JOSE.JWK.t(),
2929+ String.t() | nil
3030+ ) :: t()
3131+ def new(endpoint, did, access_token, refresh_token, expires_at, dpop_key, dpop_nonce) do
3232+ {:ok, issuer} = OAuth.get_authorization_server(endpoint)
3333+3434+ %__MODULE__{
3535+ endpoint: endpoint,
3636+ issuer: issuer,
3737+ access_token: access_token,
3838+ refresh_token: refresh_token,
3939+ did: did,
4040+ expires_at: expires_at,
4141+ dpop_nonce: dpop_nonce,
4242+ dpop_key: dpop_key
4343+ }
4444+ end
4545+4646+ @doc """
4747+ Create an OAuthClient struct from a `Plug.Conn`.
4848+4949+ Requires the conn to have passed through `Plug.Session` and
5050+ `Plug.Conn.fetch_session/2` so that the session can be acquired and have the
5151+ `atex_oauth` key fetched from it.
5252+5353+ Returns `:error` if the state is missing or is not the expected shape.
5454+ """
5555+ @spec from_conn(Plug.Conn.t()) :: {:ok, t()} | :error
5656+ def from_conn(%Plug.Conn{} = conn) do
5757+ oauth_state = Plug.Conn.get_session(conn, :atex_oauth)
5858+5959+ case oauth_state do
6060+ %{
6161+ access_token: access_token,
6262+ refresh_token: refresh_token,
6363+ did: did,
6464+ pds: pds,
6565+ expires_at: expires_at,
6666+ dpop_nonce: dpop_nonce,
6767+ dpop_key: dpop_key
6868+ } ->
6969+ {:ok, new(pds, did, access_token, refresh_token, expires_at, dpop_key, dpop_nonce)}
7070+7171+ _ ->
7272+ :error
7373+ end
7474+ end
7575+7676+ @doc """
7777+ Updates a `Plug.Conn` session with the latest values from the client.
7878+7979+ Ideally should be called at the end of routes where XRPC calls occur, in case
8080+ the client has transparently refreshed, so that the user is always up to date.
8181+ """
8282+ @spec update_plug(Plug.Conn.t(), t()) :: Plug.Conn.t()
8383+ def update_plug(%Plug.Conn{} = conn, %__MODULE__{} = client) do
8484+ Plug.Conn.put_session(conn, :atex_oauth, %{
8585+ access_token: client.access_token,
8686+ refresh_token: client.refresh_token,
8787+ did: client.did,
8888+ pds: client.endpoint,
8989+ expires_at: client.expires_at,
9090+ dpop_nonce: client.dpop_nonce,
9191+ dpop_key: client.dpop_key
9292+ })
9393+ end
9494+9595+ @doc """
9696+ Ask the client's OAuth server for a new set of auth tokens.
9797+9898+ You shouldn't need to call this manually for the most part, the client does
9999+ it's best to refresh automatically when it needs to.
100100+ """
101101+ @spec refresh(t()) :: {:ok, t()} | {:error, any()}
102102+ def refresh(%__MODULE__{} = client) do
103103+ with {:ok, authz_server} <- OAuth.get_authorization_server(client.endpoint),
104104+ {:ok, %{token_endpoint: token_endpoint}} <-
105105+ OAuth.get_authorization_server_metadata(authz_server) do
106106+ case OAuth.refresh_token(
107107+ client.refresh_token,
108108+ client.dpop_key,
109109+ client.issuer,
110110+ token_endpoint
111111+ ) do
112112+ {:ok, tokens, nonce} ->
113113+ {:ok,
114114+ %{
115115+ client
116116+ | access_token: tokens.access_token,
117117+ refresh_token: tokens.refresh_token,
118118+ dpop_nonce: nonce
119119+ }}
120120+121121+ err ->
122122+ err
123123+ end
124124+ end
125125+ end
126126+127127+ @doc """
128128+ See `Atex.XRPC.get/3`.
129129+ """
130130+ @impl true
131131+ def get(%__MODULE__{} = client, resource, opts \\ []) do
132132+ request(client, opts ++ [method: :get, url: XRPC.url(client.endpoint, resource)])
133133+ end
134134+135135+ @doc """
136136+ See `Atex.XRPC.post/3`.
137137+ """
138138+ @impl true
139139+ def post(%__MODULE__{} = client, resource, opts \\ []) do
140140+ request(client, opts ++ [method: :post, url: XRPC.url(client.endpoint, resource)])
141141+ end
142142+143143+ @spec request(t(), keyword()) :: {:ok, Req.Response.t(), t()} | {:error, any(), any()}
144144+ defp request(client, opts) do
145145+ # Preemptively refresh token if it's about to expire
146146+ with {:ok, client} <- maybe_refresh(client) do
147147+ request = opts |> Req.new() |> put_auth(client.access_token)
148148+149149+ case OAuth.request_protected_dpop_resource(
150150+ request,
151151+ client.issuer,
152152+ client.access_token,
153153+ client.dpop_key,
154154+ client.dpop_nonce
155155+ ) do
156156+ {:ok, %{status: 200} = response, nonce} ->
157157+ client = %{client | dpop_nonce: nonce}
158158+ {:ok, response, client}
159159+160160+ {:ok, response, nonce} ->
161161+ client = %{client | dpop_nonce: nonce}
162162+ handle_failure(client, response, request)
163163+164164+ err ->
165165+ err
166166+ end
167167+ end
168168+ end
169169+170170+ @spec handle_failure(t(), Req.Response.t(), Req.Request.t()) ::
171171+ {:ok, Req.Response.t(), t()} | {:error, any(), t()}
172172+ defp handle_failure(client, response, request) do
173173+ IO.inspect(response, label: "got failure")
174174+175175+ if auth_error?(response.body) and client.refresh_token do
176176+ case refresh(client) do
177177+ {:ok, client} ->
178178+ case OAuth.request_protected_dpop_resource(
179179+ request,
180180+ client.issuer,
181181+ client.access_token,
182182+ client.dpop_key,
183183+ client.dpop_nonce
184184+ ) do
185185+ {:ok, %{status: 200} = response, nonce} ->
186186+ {:ok, response, %{client | dpop_nonce: nonce}}
187187+188188+ {:ok, response, nonce} ->
189189+ {:error, response, %{client | dpop_nonce: nonce}}
190190+191191+ {:error, err} ->
192192+ {:error, err, client}
193193+ end
194194+195195+ err ->
196196+ err
197197+ end
198198+ else
199199+ {:error, response, client}
200200+ end
201201+ end
202202+203203+ @spec maybe_refresh(t(), integer()) :: {:ok, t()} | {:error, any()}
204204+ defp maybe_refresh(%__MODULE__{expires_at: expires_at} = client, buffer_minutes \\ 5) do
205205+ if token_expiring_soon?(expires_at, buffer_minutes) do
206206+ refresh(client)
207207+ else
208208+ {:ok, client}
209209+ end
210210+ end
211211+212212+ @spec token_expiring_soon?(NaiveDateTime.t(), integer()) :: boolean()
213213+ defp token_expiring_soon?(expires_at, buffer_minutes) do
214214+ now = NaiveDateTime.utc_now()
215215+ expiry_threshold = NaiveDateTime.add(now, buffer_minutes * 60, :second)
216216+217217+ NaiveDateTime.compare(expires_at, expiry_threshold) in [:lt, :eq]
218218+ end
219219+220220+ @spec auth_error?(body :: Req.Response.t()) :: boolean()
221221+ defp auth_error?(%{status: status}) when status in [401, 403], do: true
222222+ defp auth_error?(%{body: %{"error" => "InvalidToken"}}), do: true
223223+ defp auth_error?(_response), do: false
224224+225225+ @spec put_auth(Req.Request.t(), String.t()) :: Req.Request.t()
226226+ defp put_auth(request, token),
227227+ do: Req.Request.put_header(request, "authorization", "DPoP #{token}")
228228+end