···66and this project adheres to
77[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8899-<!-- ## [Unreleased] -->
99+## [Unreleased]
1010+1111+### Breaking Changes
1212+1313+- Remove `Atex.HTTP` and associated modules as the abstraction caused a bit too
1414+ much complexities for how early atex is. It may come back in the future as
1515+ something more fleshed out once we're more stable.
1616+1717+### Features
1818+1919+- Add `Atex.OAuth` module with utilites for handling some OAuth functionality.
2020+- Add `Atex.OAuth.Plug` module (if Plug is loaded) which provides a basic but
2121+ complete OAuth flow, including storing the tokens in `Plug.Session`.
10221123## [0.4.0] - 2025-08-27
1224
···11+defmodule Atex.Config.OAuth do
22+ @moduledoc """
33+ Configuration management for `Atex.OAuth`.
44+55+ Contains all the logic for fetching configuration needed for the OAuth
66+ module, as well as deriving useful values from them.
77+88+ ## Configuration
99+1010+ The following structure is expected in your application config:
1111+1212+ config :atex, Atex.OAuth,
1313+ base_url: "https://example.com/oauth", # Your application's base URL, including the path `Atex.OAuth` is mounted on.
1414+ private_key: "base64-encoded-private-key", # ES256 private key
1515+ key_id: "your-key-id", # Key identifier for JWTs
1616+ scopes: ["transition:generic", "transition:email"], # Optional additional scopes
1717+ extra_redirect_uris: ["https://alternative.com/callback"], # Optional additional redirect URIs
1818+ is_localhost: false # Set to true for local development
1919+ """
2020+2121+ @doc """
2222+ Returns the configured public base URL for OAuth routes.
2323+ """
2424+ @spec base_url() :: String.t()
2525+ def base_url, do: Application.fetch_env!(:atex, Atex.OAuth)[:base_url]
2626+2727+ @doc """
2828+ Returns the configured private key as a `JOSE.JWK`.
2929+ """
3030+ @spec get_key() :: JOSE.JWK.t()
3131+ def get_key() do
3232+ private_key =
3333+ Application.fetch_env!(:atex, Atex.OAuth)[:private_key]
3434+ |> Base.decode64!()
3535+ |> JOSE.JWK.from_der()
3636+3737+ key_id = Application.fetch_env!(:atex, Atex.OAuth)[:key_id]
3838+3939+ %{private_key | fields: %{"kid" => key_id}}
4040+ end
4141+4242+ @doc """
4343+ Returns the client ID based on configuration.
4444+4545+ If `is_localhost` is set, it'll be a string handling the "http://localhost"
4646+ special case, with the redirect URI and scopes configured, otherwise it is a
4747+ string pointing to the location of the `client-metadata.json` route.
4848+ """
4949+ @spec client_id() :: String.t()
5050+ def client_id() do
5151+ is_localhost = Keyword.get(Application.get_env(:atex, Atex.OAuth, []), :is_localhost, false)
5252+5353+ if is_localhost do
5454+ query =
5555+ %{redirect_uri: redirect_uri(), scope: scopes()}
5656+ |> URI.encode_query()
5757+5858+ "http://localhost?#{query}"
5959+ else
6060+ "#{base_url()}/client-metadata.json"
6161+ end
6262+ end
6363+6464+ @doc """
6565+ Returns the configured redirect URI.
6666+ """
6767+ @spec redirect_uri() :: String.t()
6868+ def redirect_uri(), do: "#{base_url()}/callback"
6969+7070+ @doc """
7171+ Returns the configured scopes joined as a space-separated string.
7272+ """
7373+ @spec scopes() :: String.t()
7474+ def scopes() do
7575+ config_scopes = Keyword.get(Application.get_env(:atex, Atex.OAuth, []), :scopes, [])
7676+ Enum.join(["atproto" | config_scopes], " ")
7777+ end
7878+7979+ @doc """
8080+ Returns the configured extra redirect URIs.
8181+ """
8282+ @spec extra_redirect_uris() :: [String.t()]
8383+ def extra_redirect_uris() do
8484+ Keyword.get(Application.get_env(:atex, Atex.OAuth, []), :extra_redirect_uris, [])
8585+ end
8686+end
+13
lib/atex/identity_resolver/did_document.ex
···125125 end)
126126 end
127127128128+ @spec get_pds_endpoint(t()) :: String.t() | nil
129129+ def get_pds_endpoint(%__MODULE__{} = doc) do
130130+ doc.service
131131+ |> Enum.find(fn
132132+ %{id: "#atproto_pds", type: "AtprotoPersonalDataServer"} -> true
133133+ _ -> false
134134+ end)
135135+ |> case do
136136+ nil -> nil
137137+ pds -> pds.service_endpoint
138138+ end
139139+ end
140140+128141 defp valid_pds_endpoint?(endpoint) do
129142 case URI.new(endpoint) do
130143 {:ok, uri} ->
+430
lib/atex/oauth.ex
···11+defmodule Atex.OAuth do
22+ @moduledoc """
33+ OAuth 2.0 implementation for AT Protocol authentication.
44+55+ This module provides utilities for implementing OAuth flows compliant with the
66+ AT Protocol specification. It includes support for:
77+88+ - 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
1414+1515+ ## Configuration
1616+1717+ 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+ )
3333+ """
3434+3535+ @type authorization_metadata() :: %{
3636+ issuer: String.t(),
3737+ par_endpoint: String.t(),
3838+ token_endpoint: String.t(),
3939+ authorization_endpoint: String.t()
4040+ }
4141+4242+ @type tokens() :: %{
4343+ access_token: String.t(),
4444+ refresh_token: String.t(),
4545+ did: String.t(),
4646+ expires_at: NaiveDateTime.t()
4747+ }
4848+4949+ alias Atex.Config.OAuth, as: Config
5050+5151+ @doc """
5252+ Get a map cnotaining the client metadata information needed for an
5353+ authorization server to validate this client.
5454+ """
5555+ @spec create_client_metadata() :: map()
5656+ def create_client_metadata() do
5757+ key = Config.get_key()
5858+ {_, jwk} = key |> JOSE.JWK.to_public_map()
5959+ jwk = Map.merge(jwk, %{use: "sig", kid: key.fields["kid"]})
6060+6161+ # TODO: read more about client-metadata and what specific fields mean to see that we're doing what we actually want to be doing
6262+6363+ %{
6464+ client_id: Config.client_id(),
6565+ redirect_uris: [Config.redirect_uri() | Config.extra_redirect_uris()],
6666+ application_type: "web",
6767+ grant_types: ["authorization_code", "refresh_token"],
6868+ scope: Config.scopes(),
6969+ response_type: ["code"],
7070+ token_endpoint_auth_method: "private_key_jwt",
7171+ token_endpoint_auth_signing_alg: "ES256",
7272+ dpop_bound_access_tokens: true,
7373+ jwks: %{keys: [jwk]}
7474+ }
7575+ end
7676+7777+ @doc """
7878+ Retrieves the configured JWT private key for signing client assertions.
7979+8080+ Loads the private key from configuration, decodes the base64-encoded DER data,
8181+ and creates a JOSE JWK structure with the key ID field set.
8282+8383+ ## Returns
8484+8585+ A `JOSE.JWK` struct containing the private key and key identifier.
8686+8787+ ## Raises
8888+8989+ * `Application.Env.Error` if the private_key or key_id configuration is missing
9090+9191+ ## Examples
9292+9393+ key = OAuth.get_key()
9494+ key = OAuth.get_key()
9595+ """
9696+ @spec get_key() :: JOSE.JWK.t()
9797+ def get_key(), do: Config.get_key()
9898+9999+ @doc false
100100+ @spec random_b64(integer()) :: String.t()
101101+ def random_b64(length) do
102102+ :crypto.strong_rand_bytes(length)
103103+ |> Base.url_encode64(padding: false)
104104+ end
105105+106106+ @doc false
107107+ @spec create_nonce() :: String.t()
108108+ def create_nonce(), do: random_b64(32)
109109+110110+ @doc """
111111+ Create an OAuth authorization URL for a PDS.
112112+113113+ Submits a PAR request to the authorization server and constructs the
114114+ authorization URL with the returned request URI. Supports PKCE, DPoP, and
115115+ client assertions as required by the AT Protocol.
116116+117117+ ## Parameters
118118+119119+ - `authz_metadata` - Authorization server metadata containing endpoints, fetched from `get_authorization_server_metadata/1`
120120+ - `state` - Random token for session validation
121121+ - `code_verifier` - PKCE code verifier
122122+ - `login_hint` - User identifier (handle or DID) for pre-filled login
123123+124124+ ## Returns
125125+126126+ - `{:ok, authorization_url}` - Successfully created authorization URL
127127+ - `{:ok, :invalid_par_response}` - Server respondend incorrectly to the request
128128+ - `{:error, reason}` - Error creating authorization URL
129129+ """
130130+ @spec create_authorization_url(
131131+ authorization_metadata(),
132132+ String.t(),
133133+ String.t(),
134134+ String.t()
135135+ ) :: {:ok, String.t()} | {:error, any()}
136136+ def create_authorization_url(
137137+ authz_metadata,
138138+ state,
139139+ code_verifier,
140140+ login_hint
141141+ ) do
142142+ code_challenge = :crypto.hash(:sha256, code_verifier) |> Base.url_encode64(padding: false)
143143+ key = get_key()
144144+145145+ # TODO: let keys be optional so no client assertion? is this what results in a confidential client??
146146+ client_assertion =
147147+ create_client_assertion(key, Config.client_id(), authz_metadata.issuer)
148148+149149+ body =
150150+ %{
151151+ response_type: "code",
152152+ client_id: Config.client_id(),
153153+ redirect_uri: Config.redirect_uri(),
154154+ state: state,
155155+ code_challenge_method: "S256",
156156+ code_challenge: code_challenge,
157157+ scope: Config.scopes(),
158158+ client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
159159+ client_assertion: client_assertion,
160160+ login_hint: login_hint
161161+ }
162162+163163+ case Req.post(authz_metadata.par_endpoint, form: body) do
164164+ {:ok, %{body: %{"request_uri" => request_uri}}} ->
165165+ query =
166166+ %{client_id: Config.client_id(), request_uri: request_uri}
167167+ |> URI.encode_query()
168168+169169+ {:ok, "#{authz_metadata.authorization_endpoint}?#{query}"}
170170+171171+ {:ok, _} ->
172172+ {:error, :invalid_par_response}
173173+174174+ err ->
175175+ err
176176+ end
177177+ end
178178+179179+ @doc """
180180+ Exchange an OAuth authorization code for a set of access and refresh tokens.
181181+182182+ Validates the authorization code by submitting it to the token endpoint along with
183183+ the PKCE code verifier and client assertion. Returns access tokens for making authenticated
184184+ requests to the relevant user's PDS.
185185+186186+ ## Parameters
187187+188188+ - `authz_metadata` - Authorization server metadata containing token endpoint
189189+ - `dpop_key` - JWK for DPoP token generation
190190+ - `code` - Authorization code from OAuth callback
191191+ - `code_verifier` - PKCE code verifier from authorization flow
192192+193193+ ## Returns
194194+195195+ - `{:ok, tokens, nonce}` - Successfully obtained tokens with returned DPoP nonce
196196+ - `{:error, reason}` - Error exchanging code for tokens
197197+ """
198198+ @spec validate_authorization_code(
199199+ authorization_metadata(),
200200+ JOSE.JWK.t(),
201201+ String.t(),
202202+ String.t()
203203+ ) :: {:ok, tokens(), String.t()} | {:error, any()}
204204+ def validate_authorization_code(
205205+ authz_metadata,
206206+ dpop_key,
207207+ code,
208208+ code_verifier
209209+ ) do
210210+ key = get_key()
211211+212212+ client_assertion =
213213+ create_client_assertion(key, Config.client_id(), authz_metadata.issuer)
214214+215215+ body =
216216+ %{
217217+ grant_type: "authorization_code",
218218+ client_id: Config.client_id(),
219219+ redirect_uri: Config.redirect_uri(),
220220+ code: code,
221221+ code_verifier: code_verifier,
222222+ client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
223223+ client_assertion: client_assertion
224224+ }
225225+226226+ Req.new(method: :post, url: authz_metadata.token_endpoint, form: body)
227227+ |> send_dpop_request(dpop_key)
228228+ |> case do
229229+ {:ok,
230230+ %{
231231+ "access_token" => access_token,
232232+ "refresh_token" => refresh_token,
233233+ "expires_in" => expires_in,
234234+ "sub" => did
235235+ }, nonce} ->
236236+ expires_at = NaiveDateTime.utc_now() |> NaiveDateTime.add(expires_in, :second)
237237+238238+ {:ok,
239239+ %{
240240+ access_token: access_token,
241241+ refresh_token: refresh_token,
242242+ did: did,
243243+ expires_at: expires_at
244244+ }, nonce}
245245+246246+ err ->
247247+ err
248248+ end
249249+ end
250250+251251+ @doc """
252252+ Fetch the authorization server for a given Personal Data Server (PDS).
253253+254254+ Makes a request to the PDS's `.well-known/oauth-protected-resource` endpoint
255255+ to discover the associated authorization server that should be used for the
256256+ OAuth flow.
257257+258258+ ## Parameters
259259+260260+ - `pds_host` - Base URL of the PDS (e.g., "https://bsky.social")
261261+262262+ ## Returns
263263+264264+ - `{:ok, authorization_server}` - Successfully discovered authorization
265265+ server URL
266266+ - `{:error, :invalid_metadata}` - Server returned invalid metadata
267267+ - `{:error, reason}` - Error discovering authorization server
268268+ """
269269+ @spec get_authorization_server(String.t()) :: {:ok, String.t()} | {:error, any()}
270270+ def get_authorization_server(pds_host) do
271271+ "#{pds_host}/.well-known/oauth-protected-resource"
272272+ |> Req.get()
273273+ |> case do
274274+ # TODO: what to do when multiple authorization servers?
275275+ {:ok, %{body: %{"authorization_servers" => [authz_server | _]}}} -> {:ok, authz_server}
276276+ {:ok, _} -> {:error, :invalid_metadata}
277277+ err -> err
278278+ end
279279+ end
280280+281281+ @doc """
282282+ Fetch the metadata for an OAuth authorization server.
283283+284284+ Retrieves the metadata from the authorization server's
285285+ `.well-known/oauth-authorization-server` endpoint, providing endpoint URLs
286286+ required for the OAuth flow.
287287+288288+ ## Parameters
289289+290290+ - `issuer` - Authorization server issuer URL
291291+292292+ ## Returns
293293+294294+ - `{:ok, metadata}` - Successfully retrieved authorization server metadata
295295+ - `{:error, :invalid_metadata}` - Server returned invalid metadata
296296+ - `{:error, :invalid_issuer}` - Issuer mismatch in metadata
297297+ - `{:error, any()}` - Other error fetching metadata
298298+ """
299299+ @spec get_authorization_server_metadata(String.t()) ::
300300+ {:ok, authorization_metadata()} | {:error, any()}
301301+ def get_authorization_server_metadata(issuer) do
302302+ "#{issuer}/.well-known/oauth-authorization-server"
303303+ |> Req.get()
304304+ |> case do
305305+ {:ok,
306306+ %{
307307+ body: %{
308308+ "issuer" => metadata_issuer,
309309+ "pushed_authorization_request_endpoint" => par_endpoint,
310310+ "token_endpoint" => token_endpoint,
311311+ "authorization_endpoint" => authorization_endpoint
312312+ }
313313+ }} ->
314314+ if issuer != metadata_issuer do
315315+ {:error, :invaild_issuer}
316316+ else
317317+ {:ok,
318318+ %{
319319+ issuer: metadata_issuer,
320320+ par_endpoint: par_endpoint,
321321+ token_endpoint: token_endpoint,
322322+ authorization_endpoint: authorization_endpoint
323323+ }}
324324+ end
325325+326326+ {:ok, _} ->
327327+ {:error, :invalid_metadata}
328328+329329+ err ->
330330+ err
331331+ end
332332+ end
333333+334334+ @spec send_dpop_request(Req.Request.t(), JOSE.JWK.t(), String.t() | nil) ::
335335+ {:ok, map(), String.t()} | {:error, any()}
336336+ defp send_dpop_request(request, dpop_key, nonce \\ nil) do
337337+ dpop_token = create_dpop_token(dpop_key, request, nonce)
338338+339339+ request
340340+ |> Req.Request.put_header("dpop", dpop_token)
341341+ |> Req.request()
342342+ |> case do
343343+ {:ok, req} ->
344344+ dpop_nonce =
345345+ case req.headers["dpop-nonce"] do
346346+ [new_nonce | _] -> new_nonce
347347+ _ -> nonce
348348+ end
349349+350350+ cond do
351351+ req.status == 200 ->
352352+ {:ok, req.body, dpop_nonce}
353353+354354+ req.body["error"] === "use_dpop_nonce" ->
355355+ dpop_token = create_dpop_token(dpop_key, request, dpop_nonce)
356356+357357+ request
358358+ |> Req.Request.put_header("dpop", dpop_token)
359359+ |> Req.request()
360360+ |> case do
361361+ {:ok, %{status: 200, body: body}} ->
362362+ {:ok, body, dpop_nonce}
363363+364364+ {:ok, %{body: %{"error" => error, "error_description" => error_description}}} ->
365365+ {:error, {:oauth_error, error, error_description}}
366366+367367+ {:ok, _} ->
368368+ {:error, :unexpected_response}
369369+370370+ err ->
371371+ err
372372+ end
373373+374374+ true ->
375375+ {:error, {:oauth_error, req.body["error"], req.body["error_description"]}}
376376+ end
377377+378378+ err ->
379379+ err
380380+ end
381381+ end
382382+383383+ @spec create_client_assertion(JOSE.JWK.t(), String.t(), String.t()) :: String.t()
384384+ defp create_client_assertion(jwk, client_id, issuer) do
385385+ iat = System.os_time(:second)
386386+ jti = random_b64(20)
387387+ jws = %{"alg" => "ES256", "kid" => jwk.fields["kid"]}
388388+389389+ jwt = %{
390390+ iss: client_id,
391391+ sub: client_id,
392392+ aud: issuer,
393393+ jti: jti,
394394+ iat: iat,
395395+ exp: iat + 60
396396+ }
397397+398398+ JOSE.JWT.sign(jwk, jws, jwt)
399399+ |> JOSE.JWS.compact()
400400+ |> elem(1)
401401+ end
402402+403403+ @spec create_dpop_token(JOSE.JWK.t(), Req.Request.t(), any(), map()) :: String.t()
404404+ defp create_dpop_token(jwk, request, nonce \\ nil, attrs \\ %{}) do
405405+ iat = System.os_time(:second)
406406+ jti = random_b64(20)
407407+ {_, public_jwk} = JOSE.JWK.to_public_map(jwk)
408408+ jws = %{"alg" => "ES256", "typ" => "dpop+jwt", "jwk" => public_jwk}
409409+ [request_url | _] = request.url |> to_string() |> String.split("?")
410410+411411+ jwt =
412412+ Map.merge(attrs, %{
413413+ jti: jti,
414414+ htm: atom_to_upcase_string(request.method),
415415+ htu: request_url,
416416+ iat: iat,
417417+ nonce: nonce
418418+ })
419419+420420+ JOSE.JWT.sign(jwk, jws, jwt)
421421+ |> JOSE.JWS.compact()
422422+ |> elem(1)
423423+ end
424424+425425+ @doc false
426426+ @spec atom_to_upcase_string(atom()) :: String.t()
427427+ def atom_to_upcase_string(atom) do
428428+ atom |> to_string() |> String.upcase()
429429+ end
430430+end
+151
lib/atex/oauth/plug.ex
···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.
55+66+ This module provides three endpoints:
77+88+ - `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
1212+1313+ ## Usage
1414+1515+ 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".
1818+1919+ ## Example
2020+2121+ Example implementation showing how to set up the OAuth plug with proper
2222+ session handling:
2323+2424+ defmodule ExampleOAuthPlug do
2525+ use Plug.Router
2626+2727+ plug :put_secret_key_base
2828+2929+ plug Plug.Session,
3030+ store: :cookie,
3131+ key: "atex-oauth",
3232+ signing_salt: "signing-salt"
3333+3434+ plug :match
3535+ plug :dispatch
3636+3737+ forward "/oauth", to: Atex.OAuth.Plug
3838+3939+ 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
4545+ end
4646+4747+ ## Session Storage
4848+4949+ After successful authentication, the plug stores these in the session:
5050+5151+ * `: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}
6060+6161+ @oauth_cookie_opts [path: "/", http_only: true, secure: true, same_site: "lax", max_age: 600]
6262+6363+ plug :match
6464+ plug :dispatch
6565+6666+ get "/login" do
6767+ conn = fetch_query_params(conn)
6868+ handle = conn.query_params["handle"]
6969+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()
8080+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, "")
9494+9595+ err ->
9696+ Logger.error("failed to reate authorization url, #{inspect(err)}")
9797+ send_resp(conn, 500, "Internal server error")
9898+ end
9999+100100+ {:error, err} ->
101101+ Logger.error("Failed to resolve handle, #{inspect(err)}")
102102+ send_resp(conn, 400, "Invalid handle")
103103+ end
104104+ end
105105+ end
106106+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
112112+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"]
119119+120120+ code = conn.query_params["code"]
121121+ state = conn.query_params["state"]
122122+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+ # TODO: verify did pds issuer is the same as stored issuer
136136+ ) do
137137+ IO.inspect({tokens, nonce}, label: "OAuth succeeded")
138138+139139+ 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
148148+ end
149149+ end
150150+ end
151151+end
+11-9
lib/atex/xrpc.ex
···11defmodule Atex.XRPC do
22- alias Atex.{HTTP, XRPC}
22+ alias Atex.XRPC
3344 # TODO: automatic user-agent, and env for changing it
55···1212 @doc """
1313 Perform a HTTP GET on a XRPC resource. Called a "query" in lexicons.
1414 """
1515- @spec get(XRPC.Client.t(), String.t(), keyword()) :: HTTP.Adapter.result()
1515+ @spec get(XRPC.Client.t(), String.t(), keyword()) :: {:ok, Req.Response.t()} | {:error, any()}
1616 def get(%XRPC.Client{} = client, name, opts \\ []) do
1717 opts = put_auth(opts, client.access_token)
1818- HTTP.get(url(client, name), opts)
1818+ Req.get(url(client, name), opts)
1919 end
20202121 @doc """
2222 Perform a HTTP POST on a XRPC resource. Called a "prodecure" in lexicons.
2323 """
2424- @spec post(XRPC.Client.t(), String.t(), keyword()) :: HTTP.Adapter.result()
2424+ @spec post(XRPC.Client.t(), String.t(), keyword()) :: {:ok, Req.Response.t()} | {:error, any()}
2525 def post(%XRPC.Client{} = client, name, opts \\ []) do
2626 # TODO: look through available HTTP clients and see if they have a
2727 # consistent way of providing JSON bodies with auto content-type. If not,
2828 # create one for adapters.
2929 opts = put_auth(opts, client.access_token)
3030- HTTP.post(url(client, name), opts)
3030+ Req.post(url(client, name), opts)
3131 end
32323333 @doc """
3434 Like `get/3` but is unauthenticated by default.
3535 """
3636- @spec unauthed_get(String.t(), String.t(), keyword()) :: HTTP.Adapter.result()
3636+ @spec unauthed_get(String.t(), String.t(), keyword()) ::
3737+ {:ok, Req.Response.t()} | {:error, any()}
3738 def unauthed_get(endpoint, name, opts \\ []) do
3838- HTTP.get(url(endpoint, name), opts)
3939+ Req.get(url(endpoint, name), opts)
3940 end
40414142 @doc """
4243 Like `post/3` but is unauthenticated by default.
4344 """
4444- @spec unauthed_post(String.t(), String.t(), keyword()) :: HTTP.Adapter.result()
4545+ @spec unauthed_post(String.t(), String.t(), keyword()) ::
4646+ {:ok, Req.Response.t()} | {:error, any()}
4547 def unauthed_post(endpoint, name, opts \\ []) do
4646- HTTP.post(url(endpoint, name), opts)
4848+ Req.post(url(endpoint, name), opts)
4749 end
48504951 # TODO: use URI module for joining instead?