An Elixir toolkit for the AT Protocol. hexdocs.pm/atex
elixir bluesky atproto decentralization

feat: oauth xrpc client

+483 -141
+7 -5
CHANGELOG.md
··· 15 15 something more fleshed out once we're more stable. 16 16 - Rename `Atex.XRPC.Client` to `Atex.XRPC.LoginClient` 17 17 18 - ### Features 18 + ### Added 19 19 20 - - Add `Atex.OAuth` module with utilites for handling some OAuth functionality. 21 - - Add `Atex.OAuth.Plug` module (if Plug is loaded) which provides a basic but 20 + - `Atex.OAuth` module with utilites for handling some OAuth functionality. 21 + - `Atex.OAuth.Plug` module (if Plug is loaded) which provides a basic but 22 22 complete OAuth flow, including storing the tokens in `Plug.Session`. 23 - - Add `Atex.XRPC.Client` behaviour for implementing custom client variants. 24 - - `Atex.XRPC` now delegates get/post options to the provided client struct. 23 + - `Atex.XRPC.Client` behaviour for implementing custom client variants. 24 + - `Atex.XRPC` now supports using different client implementations. 25 + - `Atex.XRPC.OAuthClient` to make XRPC calls on the behalf of a user who has 26 + authenticated with ATProto OAuth. 25 27 26 28 ## [0.4.0] - 2025-08-27 27 29
+1 -1
README.md
··· 11 11 - [x] DID & handle resolution service with a cache 12 12 - [x] Macro for converting a Lexicon definition into a runtime-validation schema 13 13 - [x] Codegen to convert a directory of lexicons 14 + - [x] Oauth stuff 14 15 - [ ] Extended XRPC client with support for validated inputs/outputs 15 - - [ ] Oauth stuff 16 16 17 17 ## Installation 18 18
+116 -17
lib/atex/oauth.ex
··· 58 58 {_, jwk} = key |> JOSE.JWK.to_public_map() 59 59 jwk = Map.merge(jwk, %{use: "sig", kid: key.fields["kid"]}) 60 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 61 %{ 64 62 client_id: Config.client_id(), 65 63 redirect_uris: [Config.redirect_uri() | Config.extra_redirect_uris()], ··· 142 140 code_challenge = :crypto.hash(:sha256, code_verifier) |> Base.url_encode64(padding: false) 143 141 key = get_key() 144 142 145 - # TODO: let keys be optional so no client assertion? is this what results in a confidential client?? 146 143 client_assertion = 147 144 create_client_assertion(key, Config.client_id(), authz_metadata.issuer) 148 145 ··· 224 221 } 225 222 226 223 Req.new(method: :post, url: authz_metadata.token_endpoint, form: body) 227 - |> send_dpop_request(dpop_key) 224 + |> send_oauth_dpop_request(dpop_key) 225 + |> case do 226 + {:ok, 227 + %{ 228 + "access_token" => access_token, 229 + "refresh_token" => refresh_token, 230 + "expires_in" => expires_in, 231 + "sub" => did 232 + }, nonce} -> 233 + expires_at = NaiveDateTime.utc_now() |> NaiveDateTime.add(expires_in, :second) 234 + 235 + {:ok, 236 + %{ 237 + access_token: access_token, 238 + refresh_token: refresh_token, 239 + did: did, 240 + expires_at: expires_at 241 + }, nonce} 242 + 243 + err -> 244 + err 245 + end 246 + end 247 + 248 + def refresh_token(refresh_token, dpop_key, issuer, token_endpoint) do 249 + key = get_key() 250 + 251 + client_assertion = 252 + create_client_assertion(key, Config.client_id(), issuer) 253 + 254 + body = %{ 255 + grant_type: "refresh_token", 256 + refresh_token: refresh_token, 257 + client_id: Config.client_id(), 258 + client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", 259 + client_assertion: client_assertion 260 + } 261 + 262 + Req.new(method: :post, url: token_endpoint, form: body) 263 + |> send_oauth_dpop_request(dpop_key) 228 264 |> case do 229 265 {:ok, 230 266 %{ ··· 331 367 end 332 368 end 333 369 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 370 + @spec send_oauth_dpop_request(Req.Request.t(), JOSE.JWK.t(), String.t() | nil) :: 371 + {:ok, map(), String.t()} | {:error, any(), String.t()} 372 + def send_oauth_dpop_request(request, dpop_key, nonce \\ nil) do 337 373 dpop_token = create_dpop_token(dpop_key, request, nonce) 338 374 339 375 request 340 376 |> Req.Request.put_header("dpop", dpop_token) 341 377 |> Req.request() 342 378 |> case do 343 - {:ok, req} -> 379 + {:ok, resp} -> 344 380 dpop_nonce = 345 - case req.headers["dpop-nonce"] do 381 + case resp.headers["dpop-nonce"] do 346 382 [new_nonce | _] -> new_nonce 347 383 _ -> nonce 348 384 end 349 385 350 386 cond do 351 - req.status == 200 -> 352 - {:ok, req.body, dpop_nonce} 387 + resp.status == 200 -> 388 + {:ok, resp.body, dpop_nonce} 353 389 354 - req.body["error"] === "use_dpop_nonce" -> 390 + resp.body["error"] === "use_dpop_nonce" -> 355 391 dpop_token = create_dpop_token(dpop_key, request, dpop_nonce) 356 392 357 393 request ··· 362 398 {:ok, body, dpop_nonce} 363 399 364 400 {:ok, %{body: %{"error" => error, "error_description" => error_description}}} -> 365 - {:error, {:oauth_error, error, error_description}} 401 + {:error, {:oauth_error, error, error_description}, dpop_nonce} 366 402 367 403 {:ok, _} -> 368 - {:error, :unexpected_response} 404 + {:error, :unexpected_response, dpop_nonce} 369 405 370 - err -> 371 - err 406 + {:error, err} -> 407 + {:error, err, dpop_nonce} 372 408 end 373 409 374 410 true -> 375 - {:error, {:oauth_error, req.body["error"], req.body["error_description"]}} 411 + {:error, {:oauth_error, resp.body["error"], resp.body["error_description"]}, 412 + dpop_nonce} 413 + end 414 + 415 + {:error, err} -> 416 + {:error, err, nonce} 417 + end 418 + end 419 + 420 + @spec request_protected_dpop_resource( 421 + Req.Request.t(), 422 + String.t(), 423 + String.t(), 424 + JOSE.JWK.t(), 425 + String.t() | nil 426 + ) :: {:ok, Req.Response.t(), String.t() | nil} | {:error, any()} 427 + def request_protected_dpop_resource(request, issuer, access_token, dpop_key, nonce \\ nil) do 428 + access_token_hash = :crypto.hash(:sha256, access_token) |> Base.url_encode64(padding: false) 429 + # access_token_hash = Base.url_encode64(access_token, padding: false) 430 + 431 + dpop_token = 432 + create_dpop_token(dpop_key, request, nonce, %{iss: issuer, ath: access_token_hash}) 433 + 434 + request 435 + |> Req.Request.put_header("dpop", dpop_token) 436 + |> Req.request() 437 + |> case do 438 + {:ok, resp} -> 439 + dpop_nonce = 440 + case resp.headers["dpop-nonce"] do 441 + [new_nonce | _] -> new_nonce 442 + _ -> nonce 443 + end 444 + 445 + www_authenticate = Req.Response.get_header(resp, "www-authenticate") 446 + 447 + www_dpop_problem = 448 + www_authenticate != [] && String.starts_with?(Enum.at(www_authenticate, 0), "DPoP") 449 + 450 + if resp.status != 401 || !www_dpop_problem do 451 + {:ok, resp, dpop_nonce} 452 + else 453 + dpop_token = 454 + create_dpop_token(dpop_key, request, dpop_nonce, %{ 455 + iss: issuer, 456 + ath: access_token_hash 457 + }) 458 + 459 + request 460 + |> Req.Request.put_header("dpop", dpop_token) 461 + |> Req.request() 462 + |> case do 463 + {:ok, resp} -> 464 + dpop_nonce = 465 + case resp.headers["dpop-nonce"] do 466 + [new_nonce | _] -> new_nonce 467 + _ -> dpop_nonce 468 + end 469 + 470 + {:ok, resp, dpop_nonce} 471 + 472 + err -> 473 + err 474 + end 376 475 end 377 476 378 477 err ->
+130 -116
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. 1 + defmodule Atex.OAuth.Plug do 2 + @moduledoc """ 3 + Plug router for handling AT Protocol's OAuth flow. 5 4 6 - This module provides three endpoints: 5 + This module provides three endpoints: 7 6 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 7 + - `GET /login?handle=<handle>` - Initiates the OAuth authorization flow for 8 + a given handle 9 + - `GET /callback` - Handles the OAuth callback after user authorization 10 + - `GET /client-metadata.json` - Serves the OAuth client metadata 12 11 13 - ## Usage 12 + ## Usage 14 13 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". 14 + This module requires `Plug.Session` to be in your pipeline, as well as 15 + `secret_key_base` to have been set on your connections. Ideally it should be 16 + routed to via `Plug.Router.forward/2`, under a route like "/oauth". 18 17 19 - ## Example 18 + ## Example 20 19 21 - Example implementation showing how to set up the OAuth plug with proper 22 - session handling: 20 + Example implementation showing how to set up the OAuth plug with proper 21 + session handling: 23 22 24 - defmodule ExampleOAuthPlug do 25 - use Plug.Router 23 + defmodule ExampleOAuthPlug do 24 + use Plug.Router 26 25 27 - plug :put_secret_key_base 26 + plug :put_secret_key_base 28 27 29 - plug Plug.Session, 30 - store: :cookie, 31 - key: "atex-oauth", 32 - signing_salt: "signing-salt" 28 + plug Plug.Session, 29 + store: :cookie, 30 + key: "atex-oauth", 31 + signing_salt: "signing-salt" 33 32 34 - plug :match 35 - plug :dispatch 33 + plug :match 34 + plug :dispatch 36 35 37 - forward "/oauth", to: Atex.OAuth.Plug 36 + forward "/oauth", to: Atex.OAuth.Plug 38 37 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 38 + def put_secret_key_base(conn, _) do 39 + put_in( 40 + conn.secret_key_base, 41 + "very long key base with at least 64 bytes" 42 + ) 45 43 end 44 + end 46 45 47 - ## Session Storage 46 + ## Session Storage 48 47 49 - After successful authentication, the plug stores these in the session: 48 + After successful authentication, the plug stores these in the session: 50 49 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} 50 + - `:tokens` - The access token response containing access_token, 51 + refresh_token, did, and expires_at 52 + - `:dpop_nonce` - 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 60 61 - @oauth_cookie_opts [path: "/", http_only: true, secure: true, same_site: "lax", max_age: 600] 61 + @oauth_cookie_opts [path: "/", http_only: true, secure: true, same_site: "lax", max_age: 600] 62 62 63 - plug :match 64 - plug :dispatch 63 + plug :match 64 + plug :dispatch 65 65 66 - get "/login" do 67 - conn = fetch_query_params(conn) 68 - handle = conn.query_params["handle"] 66 + get "/login" do 67 + conn = fetch_query_params(conn) 68 + handle = conn.query_params["handle"] 69 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() 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 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, "") 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 94 95 - err -> 96 - Logger.error("failed to reate authorization url, #{inspect(err)}") 97 - send_resp(conn, 500, "Internal server error") 98 - end 95 + err -> 96 + Logger.error("failed to reate authorization url, #{inspect(err)}") 97 + send_resp(conn, 500, "Internal server error") 98 + end 99 99 100 - {:error, err} -> 101 - Logger.error("Failed to resolve handle, #{inspect(err)}") 102 - send_resp(conn, 400, "Invalid handle") 103 - end 100 + {:error, err} -> 101 + Logger.error("Failed to resolve handle, #{inspect(err)}") 102 + send_resp(conn, 400, "Invalid handle") 104 103 end 105 104 end 105 + end 106 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 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 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"] 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 119 120 - code = conn.query_params["code"] 121 - state = conn.query_params["state"] 120 + code = conn.query_params["code"] 121 + state = conn.query_params["state"] 122 122 123 - if !stored_state || !stored_code_verifier || !stored_issuer || (!code || !state) || 124 - stored_state != state do 125 - send_resp(conn, 400, "Invalid request") 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 + ), 136 + {:ok, identity} <- IdentityResolver.resolve(tokens.did), 137 + # Make sure pds' issuer matches the stored one (just in case) 138 + pds <- DIDDocument.get_pds_endpoint(identity.document), 139 + {:ok, authz_server} <- OAuth.get_authorization_server(pds), 140 + true <- authz_server == stored_issuer do 141 + conn 142 + |> delete_resp_cookie("state", @oauth_cookie_opts) 143 + |> delete_resp_cookie("code_verifier", @oauth_cookie_opts) 144 + |> delete_resp_cookie("issuer", @oauth_cookie_opts) 145 + |> put_session(:atex_oauth, %{ 146 + access_token: tokens.access_token, 147 + refresh_token: tokens.refresh_token, 148 + did: tokens.did, 149 + pds: pds, 150 + expires_at: tokens.expires_at, 151 + dpop_nonce: nonce, 152 + dpop_key: dpop_key 153 + }) 154 + |> send_resp(200, "success!! hello #{tokens.did}") 126 155 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") 156 + false -> 157 + send_resp(conn, 400, "OAuth issuer does not match your PDS' authorization server") 138 158 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 159 + err -> 160 + Logger.error("failed to validate oauth callback: #{inspect(err)}") 161 + send_resp(conn, 500, "Internal server error") 148 162 end 149 163 end 150 164 end
-1
lib/atex/xrpc.ex
··· 68 68 Req.post(url(endpoint, name), opts) 69 69 end 70 70 71 - # TODO: use URI module for joining instead? 72 71 @doc """ 73 72 Create an XRPC url based on an endpoint and a resource name. 74 73
+228
lib/atex/xrpc/oauth_client.ex
··· 1 + defmodule Atex.XRPC.OAuthClient do 2 + alias Atex.OAuth 3 + alias Atex.XRPC 4 + use TypedStruct 5 + 6 + @behaviour Atex.XRPC.Client 7 + 8 + typedstruct enforce: true do 9 + field :endpoint, String.t() 10 + field :issuer, String.t() 11 + field :access_token, String.t() 12 + field :refresh_token, String.t() 13 + field :did, String.t() 14 + field :expires_at, NaiveDateTime.t() 15 + field :dpop_nonce, String.t() | nil, enforce: false 16 + field :dpop_key, JOSE.JWK.t() 17 + end 18 + 19 + @doc """ 20 + Create a new OAuthClient struct. 21 + """ 22 + @spec new( 23 + String.t(), 24 + String.t(), 25 + String.t(), 26 + String.t(), 27 + NaiveDateTime.t(), 28 + JOSE.JWK.t(), 29 + String.t() | nil 30 + ) :: t() 31 + def new(endpoint, did, access_token, refresh_token, expires_at, dpop_key, dpop_nonce) do 32 + {:ok, issuer} = OAuth.get_authorization_server(endpoint) 33 + 34 + %__MODULE__{ 35 + endpoint: endpoint, 36 + issuer: issuer, 37 + access_token: access_token, 38 + refresh_token: refresh_token, 39 + did: did, 40 + expires_at: expires_at, 41 + dpop_nonce: dpop_nonce, 42 + dpop_key: dpop_key 43 + } 44 + end 45 + 46 + @doc """ 47 + Create an OAuthClient struct from a `Plug.Conn`. 48 + 49 + Requires the conn to have passed through `Plug.Session` and 50 + `Plug.Conn.fetch_session/2` so that the session can be acquired and have the 51 + `atex_oauth` key fetched from it. 52 + 53 + Returns `:error` if the state is missing or is not the expected shape. 54 + """ 55 + @spec from_conn(Plug.Conn.t()) :: {:ok, t()} | :error 56 + def from_conn(%Plug.Conn{} = conn) do 57 + oauth_state = Plug.Conn.get_session(conn, :atex_oauth) 58 + 59 + case oauth_state do 60 + %{ 61 + access_token: access_token, 62 + refresh_token: refresh_token, 63 + did: did, 64 + pds: pds, 65 + expires_at: expires_at, 66 + dpop_nonce: dpop_nonce, 67 + dpop_key: dpop_key 68 + } -> 69 + {:ok, new(pds, did, access_token, refresh_token, expires_at, dpop_key, dpop_nonce)} 70 + 71 + _ -> 72 + :error 73 + end 74 + end 75 + 76 + @doc """ 77 + Updates a `Plug.Conn` session with the latest values from the client. 78 + 79 + Ideally should be called at the end of routes where XRPC calls occur, in case 80 + the client has transparently refreshed, so that the user is always up to date. 81 + """ 82 + @spec update_plug(Plug.Conn.t(), t()) :: Plug.Conn.t() 83 + def update_plug(%Plug.Conn{} = conn, %__MODULE__{} = client) do 84 + Plug.Conn.put_session(conn, :atex_oauth, %{ 85 + access_token: client.access_token, 86 + refresh_token: client.refresh_token, 87 + did: client.did, 88 + pds: client.endpoint, 89 + expires_at: client.expires_at, 90 + dpop_nonce: client.dpop_nonce, 91 + dpop_key: client.dpop_key 92 + }) 93 + end 94 + 95 + @doc """ 96 + Ask the client's OAuth server for a new set of auth tokens. 97 + 98 + You shouldn't need to call this manually for the most part, the client does 99 + it's best to refresh automatically when it needs to. 100 + """ 101 + @spec refresh(t()) :: {:ok, t()} | {:error, any()} 102 + def refresh(%__MODULE__{} = client) do 103 + with {:ok, authz_server} <- OAuth.get_authorization_server(client.endpoint), 104 + {:ok, %{token_endpoint: token_endpoint}} <- 105 + OAuth.get_authorization_server_metadata(authz_server) do 106 + case OAuth.refresh_token( 107 + client.refresh_token, 108 + client.dpop_key, 109 + client.issuer, 110 + token_endpoint 111 + ) do 112 + {:ok, tokens, nonce} -> 113 + {:ok, 114 + %{ 115 + client 116 + | access_token: tokens.access_token, 117 + refresh_token: tokens.refresh_token, 118 + dpop_nonce: nonce 119 + }} 120 + 121 + err -> 122 + err 123 + end 124 + end 125 + end 126 + 127 + @doc """ 128 + See `Atex.XRPC.get/3`. 129 + """ 130 + @impl true 131 + def get(%__MODULE__{} = client, resource, opts \\ []) do 132 + request(client, opts ++ [method: :get, url: XRPC.url(client.endpoint, resource)]) 133 + end 134 + 135 + @doc """ 136 + See `Atex.XRPC.post/3`. 137 + """ 138 + @impl true 139 + def post(%__MODULE__{} = client, resource, opts \\ []) do 140 + request(client, opts ++ [method: :post, url: XRPC.url(client.endpoint, resource)]) 141 + end 142 + 143 + @spec request(t(), keyword()) :: {:ok, Req.Response.t(), t()} | {:error, any(), any()} 144 + defp request(client, opts) do 145 + # Preemptively refresh token if it's about to expire 146 + with {:ok, client} <- maybe_refresh(client) do 147 + request = opts |> Req.new() |> put_auth(client.access_token) 148 + 149 + case OAuth.request_protected_dpop_resource( 150 + request, 151 + client.issuer, 152 + client.access_token, 153 + client.dpop_key, 154 + client.dpop_nonce 155 + ) do 156 + {:ok, %{status: 200} = response, nonce} -> 157 + client = %{client | dpop_nonce: nonce} 158 + {:ok, response, client} 159 + 160 + {:ok, response, nonce} -> 161 + client = %{client | dpop_nonce: nonce} 162 + handle_failure(client, response, request) 163 + 164 + err -> 165 + err 166 + end 167 + end 168 + end 169 + 170 + @spec handle_failure(t(), Req.Response.t(), Req.Request.t()) :: 171 + {:ok, Req.Response.t(), t()} | {:error, any(), t()} 172 + defp handle_failure(client, response, request) do 173 + IO.inspect(response, label: "got failure") 174 + 175 + if auth_error?(response.body) and client.refresh_token do 176 + case refresh(client) do 177 + {:ok, client} -> 178 + case OAuth.request_protected_dpop_resource( 179 + request, 180 + client.issuer, 181 + client.access_token, 182 + client.dpop_key, 183 + client.dpop_nonce 184 + ) do 185 + {:ok, %{status: 200} = response, nonce} -> 186 + {:ok, response, %{client | dpop_nonce: nonce}} 187 + 188 + {:ok, response, nonce} -> 189 + {:error, response, %{client | dpop_nonce: nonce}} 190 + 191 + {:error, err} -> 192 + {:error, err, client} 193 + end 194 + 195 + err -> 196 + err 197 + end 198 + else 199 + {:error, response, client} 200 + end 201 + end 202 + 203 + @spec maybe_refresh(t(), integer()) :: {:ok, t()} | {:error, any()} 204 + defp maybe_refresh(%__MODULE__{expires_at: expires_at} = client, buffer_minutes \\ 5) do 205 + if token_expiring_soon?(expires_at, buffer_minutes) do 206 + refresh(client) 207 + else 208 + {:ok, client} 209 + end 210 + end 211 + 212 + @spec token_expiring_soon?(NaiveDateTime.t(), integer()) :: boolean() 213 + defp token_expiring_soon?(expires_at, buffer_minutes) do 214 + now = NaiveDateTime.utc_now() 215 + expiry_threshold = NaiveDateTime.add(now, buffer_minutes * 60, :second) 216 + 217 + NaiveDateTime.compare(expires_at, expiry_threshold) in [:lt, :eq] 218 + end 219 + 220 + @spec auth_error?(body :: Req.Response.t()) :: boolean() 221 + defp auth_error?(%{status: status}) when status in [401, 403], do: true 222 + defp auth_error?(%{body: %{"error" => "InvalidToken"}}), do: true 223 + defp auth_error?(_response), do: false 224 + 225 + @spec put_auth(Req.Request.t(), String.t()) :: Req.Request.t() 226 + defp put_auth(request, token), 227 + do: Req.Request.put_header(request, "authorization", "DPoP #{token}") 228 + end
+1 -1
mix.exs
··· 36 36 {:ex_cldr, "~> 2.42"}, 37 37 {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, 38 38 {:ex_doc, "~> 0.34", only: :dev, runtime: false, warn_if_outdated: true}, 39 - {:plug, "~> 1.18", optional: true}, 39 + {:plug, "~> 1.18"}, 40 40 {:jose, git: "https://github.com/potatosalad/erlang-jose.git", ref: "main"}, 41 41 {:bandit, "~> 1.0", only: [:dev, :test]} 42 42 ]