A set of utilities for working with the AT Protocol in Elixir.

feat: basic OAuth Plug and utils

ovyerus.com 1164a1e3 81de840e

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