···6and this project adheres to
7[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
89-<!-- ## [Unreleased] -->
0000000000001011## [0.4.0] - 2025-08-27
12
···6and this project adheres to
7[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
89+## [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`.
2223## [0.4.0] - 2025-08-27
24
···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
···125 end)
126 end
1270000000000000128 defp valid_pds_endpoint?(endpoint) do
129 case URI.new(endpoint) do
130 {:ok, uri} ->
···125 end)
126 end
127128+ @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+141 defp valid_pds_endpoint?(endpoint) do
142 case URI.new(endpoint) do
143 {:ok, uri} ->
···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
···1defmodule Atex.XRPC do
2- alias Atex.{HTTP, XRPC}
34 # TODO: automatic user-agent, and env for changing it
5···12 @doc """
13 Perform a HTTP GET on a XRPC resource. Called a "query" in lexicons.
14 """
15- @spec get(XRPC.Client.t(), String.t(), keyword()) :: HTTP.Adapter.result()
16 def get(%XRPC.Client{} = client, name, opts \\ []) do
17 opts = put_auth(opts, client.access_token)
18- HTTP.get(url(client, name), opts)
19 end
2021 @doc """
22 Perform a HTTP POST on a XRPC resource. Called a "prodecure" in lexicons.
23 """
24- @spec post(XRPC.Client.t(), String.t(), keyword()) :: HTTP.Adapter.result()
25 def post(%XRPC.Client{} = client, name, opts \\ []) do
26 # TODO: look through available HTTP clients and see if they have a
27 # consistent way of providing JSON bodies with auto content-type. If not,
28 # create one for adapters.
29 opts = put_auth(opts, client.access_token)
30- HTTP.post(url(client, name), opts)
31 end
3233 @doc """
34 Like `get/3` but is unauthenticated by default.
35 """
36- @spec unauthed_get(String.t(), String.t(), keyword()) :: HTTP.Adapter.result()
037 def unauthed_get(endpoint, name, opts \\ []) do
38- HTTP.get(url(endpoint, name), opts)
39 end
4041 @doc """
42 Like `post/3` but is unauthenticated by default.
43 """
44- @spec unauthed_post(String.t(), String.t(), keyword()) :: HTTP.Adapter.result()
045 def unauthed_post(endpoint, name, opts \\ []) do
46- HTTP.post(url(endpoint, name), opts)
47 end
4849 # TODO: use URI module for joining instead?
···1defmodule Atex.XRPC do
2+ alias Atex.XRPC
34 # TODO: automatic user-agent, and env for changing it
5···12 @doc """
13 Perform a HTTP GET on a XRPC resource. Called a "query" in lexicons.
14 """
15+ @spec get(XRPC.Client.t(), String.t(), keyword()) :: {:ok, Req.Response.t()} | {:error, any()}
16 def get(%XRPC.Client{} = client, name, opts \\ []) do
17 opts = put_auth(opts, client.access_token)
18+ Req.get(url(client, name), opts)
19 end
2021 @doc """
22 Perform a HTTP POST on a XRPC resource. Called a "prodecure" in lexicons.
23 """
24+ @spec post(XRPC.Client.t(), String.t(), keyword()) :: {:ok, Req.Response.t()} | {:error, any()}
25 def post(%XRPC.Client{} = client, name, opts \\ []) do
26 # TODO: look through available HTTP clients and see if they have a
27 # consistent way of providing JSON bodies with auto content-type. If not,
28 # create one for adapters.
29 opts = put_auth(opts, client.access_token)
30+ Req.post(url(client, name), opts)
31 end
3233 @doc """
34 Like `get/3` but is unauthenticated by default.
35 """
36+ @spec unauthed_get(String.t(), String.t(), keyword()) ::
37+ {:ok, Req.Response.t()} | {:error, any()}
38 def unauthed_get(endpoint, name, opts \\ []) do
39+ Req.get(url(endpoint, name), opts)
40 end
4142 @doc """
43 Like `post/3` but is unauthenticated by default.
44 """
45+ @spec unauthed_post(String.t(), String.t(), keyword()) ::
46+ {:ok, Req.Response.t()} | {:error, any()}
47 def unauthed_post(endpoint, name, opts \\ []) do
48+ Req.post(url(endpoint, name), opts)
49 end
5051 # TODO: use URI module for joining instead?