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

Configure Feed

Select the types of activity you want to include in your feed.

feat(oauth): logout route and session revocation helpers

+227 -15
+3
CHANGELOG.md
··· 28 28 and writing to a JSON file. 29 29 - Sigils for `Atex.AtURI` and `Atex.TID`, `~AT"at://..."` and `~TID"..."` 30 30 respectively. 31 + - `/logout` route for `Atex.OAuth.Plug` to revoke the current session, as well 32 + as `Atex.OAuth.Plug.revoke_session/2` to revoke a conn's session 33 + programmaticly (e.g. from a session management dashboard). 31 34 32 35 ## [0.8.0] - 2026-03-29 33 36
+18 -5
examples/oauth.ex
··· 15 15 plug :match 16 16 plug :dispatch 17 17 18 - forward "/oauth", to: Atex.OAuth.Plug, init_opts: [callback: {__MODULE__, :oauth_callback, []}] 18 + forward "/oauth", 19 + to: Atex.OAuth.Plug, 20 + init_opts: [ 21 + callback: {__MODULE__, :oauth_callback, []}, 22 + logout_callback: {__MODULE__, :logout_callback, []} 23 + ] 19 24 20 25 def oauth_callback(conn) do 21 26 IO.inspect(conn, label: "callback from oauth!") ··· 26 31 |> send_resp() 27 32 end 28 33 34 + def logout_callback(conn) do 35 + conn 36 + |> put_resp_header("Location", "/") 37 + |> resp(302, "") 38 + |> send_resp() 39 + end 40 + 29 41 get "/whoami" do 30 42 conn = fetch_session(conn) 31 43 32 44 case XRPC.OAuthClient.from_conn(conn) do 33 45 {:ok, client} -> 34 - send_resp(conn, 200, "hello #{client.did}") 46 + did = XRPC.OAuthClient.did(client) 47 + send_resp(conn, 200, "hello #{did}") 35 48 36 49 :error -> 37 50 send_resp(conn, 401, "Unauthorized") ··· 43 56 44 57 with {:ok, client} <- XRPC.OAuthClient.from_conn(conn), 45 58 {:ok, response, client} <- 46 - XRPC.post(client, %Com.Atproto.Repo.CreateRecord{ 47 - input: %Com.Atproto.Repo.CreateRecord.Input{ 59 + XRPC.post(client, "com.atproto.repo.createRecord", 60 + json: %{ 48 61 repo: client.did, 49 62 collection: "app.bsky.feed.post", 50 63 rkey: Atex.TID.now() |> to_string(), ··· 54 67 createdAt: DateTime.to_iso8601(DateTime.utc_now()) 55 68 } 56 69 } 57 - }) do 70 + ) do 58 71 IO.inspect(response, label: "output") 59 72 60 73 send_resp(conn, 200, response.body.uri)
+102 -5
lib/atex/oauth.ex
··· 36 36 issuer: String.t(), 37 37 par_endpoint: String.t(), 38 38 token_endpoint: String.t(), 39 - authorization_endpoint: String.t() 39 + authorization_endpoint: String.t(), 40 + revocation_endpoint: String.t() 40 41 } 41 42 42 43 @type tokens() :: %{ ··· 71 72 | {:redirect_uri, String.t()} 72 73 | {:scopes, String.t()} 73 74 75 + require Logger 76 + 74 77 alias Atex.Config.OAuth, as: Config 78 + alias Atex.OAuth.{Session, SessionStore} 75 79 76 80 @session_keys_name :atex_sessions 77 81 @session_active_name :atex_active_session ··· 544 548 "issuer" => metadata_issuer, 545 549 "pushed_authorization_request_endpoint" => par_endpoint, 546 550 "token_endpoint" => token_endpoint, 547 - "authorization_endpoint" => authorization_endpoint 551 + "authorization_endpoint" => authorization_endpoint, 552 + "revocation_endpoint" => revocation_endpoint 548 553 } 549 554 }} -> 550 555 if issuer != metadata_issuer do ··· 555 560 issuer: metadata_issuer, 556 561 par_endpoint: par_endpoint, 557 562 token_endpoint: token_endpoint, 558 - authorization_endpoint: authorization_endpoint 563 + authorization_endpoint: authorization_endpoint, 564 + revocation_endpoint: revocation_endpoint 559 565 }} 560 566 end 561 567 ··· 607 613 {:ok, body, dpop_nonce} 608 614 609 615 {:ok, %{body: %{"error" => error, "error_description" => error_description}}} -> 616 + IO.inspect(request) 610 617 {:error, {:oauth_error, error, error_description}, dpop_nonce} 611 618 612 619 {:ok, _} -> ··· 617 624 end 618 625 619 626 true -> 627 + IO.inspect(request) 628 + 620 629 {:error, {:oauth_error, resp.body["error"], resp.body["error_description"]}, 621 630 dpop_nonce} 622 631 end ··· 682 691 err 683 692 end 684 693 end 694 + end 695 + end 685 696 686 - err -> 687 - err 697 + @doc """ 698 + Revokes the access and refresh tokens with the authorization server. 699 + 700 + Sends both tokens to the revocation endpoint as defined in RFC 7009. 701 + This invalidates the tokens on the PDS side, preventing further use. 702 + 703 + ## Parameters 704 + 705 + - `session` - The session containing tokens to revoke 706 + - `authz_metadata` - Authorization server metadata including `revocation_endpoint` 707 + 708 + ## Returns 709 + 710 + - `:ok` - Tokens successfully revoked (or revocation endpoint unreachable) 711 + - `{:error, reason}` - Revocation failed 712 + 713 + """ 714 + @spec revoke_tokens(Session.t(), authorization_metadata()) :: :ok | {:error, any()} 715 + def revoke_tokens(%Session{} = session, authz_metadata) do 716 + client_id = Config.client_id() 717 + 718 + body = %{ 719 + client_id: client_id, 720 + token: session.refresh_token, 721 + token_type_hint: "refresh_token" 722 + } 723 + 724 + case Req.post(authz_metadata.revocation_endpoint, form: body) do 725 + {:ok, %{status: status}} when status in [200, 204] -> 726 + :ok 727 + 728 + {:ok, %{body: %{"error" => error}}} -> 729 + Logger.warning("Token revocation failed: #{error}") 730 + :ok 731 + 732 + {:error, reason} -> 733 + Logger.warning("Token revocation request failed: #{inspect(reason)}") 734 + :ok 735 + 736 + unexpected -> 737 + Logger.warning("Unexpected token revocation response: #{inspect(unexpected)}") 738 + :ok 739 + end 740 + end 741 + 742 + @doc """ 743 + Deletes a session from the store and revokes its tokens. 744 + 745 + This is the primary function for logging out a session. It: 746 + 1. Fetches the session data from the store if a key is provided 747 + 2. Revokes the tokens with the authorization server 748 + 3. Removes the session from the store 749 + 750 + ## Parameters 751 + 752 + - `session_or_key` - Either a `Session.t()` struct or a composite session key string 753 + 754 + ## Returns 755 + 756 + - `:ok` - Session deleted and tokens revoked 757 + - `{:error, :not_found}` - Session not found in store 758 + - `{:error, reason}` - Token revocation or store deletion failed 759 + 760 + ## Examples 761 + 762 + # Using a session key 763 + case Atex.OAuth.delete_session("did:plc:abc123:device-nonce") do 764 + :ok -> :logged_out 765 + {:error, :not_found} -> :session_already_gone 766 + end 767 + 768 + # Using a session struct 769 + {:ok, session} = Atex.OAuth.SessionStore.get("did:plc:abc123:device-nonce") 770 + :ok = Atex.OAuth.delete_session(session) 771 + 772 + """ 773 + @spec delete_session(Session.t() | String.t()) :: :ok | {:error, :not_found | any()} 774 + def delete_session(%Session{} = session) do 775 + with {:ok, authz_metadata} <- get_authorization_server_metadata(session.iss, true), 776 + :ok <- revoke_tokens(session, authz_metadata) do 777 + SessionStore.delete(session) 778 + end 779 + end 780 + 781 + def delete_session(session_key) when is_binary(session_key) do 782 + case SessionStore.get(session_key) do 783 + {:ok, session} -> delete_session(session) 784 + {:error, reason} -> {:error, reason} 688 785 end 689 786 end 690 787
+2
lib/atex/oauth/error.ex
··· 22 22 """ 23 23 24 24 defexception [:message, :reason] 25 + 26 + def message(exception), do: "#{exception.message}. reason: #{exception.reason}" 25 27 end
+102 -5
lib/atex/oauth/plug.ex
··· 2 2 @moduledoc """ 3 3 Plug router for handling AT Protocol's OAuth flow. 4 4 5 - This module provides three endpoints: 5 + This module provides four endpoints: 6 6 7 7 - `GET /login?handle=<handle>` - Initiates the OAuth authorization flow for a 8 8 given handle 9 9 - `GET /callback` - Handles the OAuth callback after user authorization 10 10 - `GET /client-metadata.json` - Serves the OAuth client metadata 11 + - `GET /logout` - Logs out the current session and revokes tokens 11 12 12 13 ## Usage 13 14 ··· 19 20 Function, Args). This callback is invoked after successful OAuth 20 21 authentication, receiving the connection with the authenticated session data. 21 22 23 + An optional `:logout_callback` option can be provided for handling logout 24 + redirects. If not provided, the user is redirected to "/". 25 + 22 26 ## Error Handling 23 27 24 28 `Atex.OAuth.Error` exceptions are raised when errors occur during the OAuth ··· 29 33 ## Example 30 34 31 35 Example implementation showing how to set up the OAuth plug with proper 32 - session handling, error handling, and a callback function. 36 + session handling, error handling, and callbacks. 33 37 34 38 defmodule ExampleOAuthPlug do 35 39 use Plug.Router ··· 45 49 plug :match 46 50 plug :dispatch 47 51 48 - forward "/oauth", to: Atex.OAuth.Plug, init_opts: [callback: {__MODULE__, :oauth_callback, []}] 52 + forward "/oauth", to: Atex.OAuth.Plug, 53 + init_opts: [ 54 + callback: {__MODULE__, :oauth_callback, []}, 55 + logout_callback: {__MODULE__, :logout_callback, []} 56 + ] 49 57 50 58 def oauth_callback(conn) do 51 59 # Handle successful OAuth authentication ··· 55 63 |> send_resp() 56 64 end 57 65 66 + def logout_callback(conn) do 67 + # Handle logout redirect 68 + conn 69 + |> put_resp_header("Location", "/login") 70 + |> resp(307, "") 71 + |> send_resp() 72 + end 73 + 58 74 def put_secret_key_base(conn, _) do 59 75 put_in( 60 76 conn.secret_key_base, ··· 111 127 raise "expected callback to be a MFA tuple" 112 128 end 113 129 130 + logout_callback = Keyword.get(opts, :logout_callback, nil) 131 + 132 + if logout_callback && !match?({_module, _function, _args}, logout_callback) do 133 + raise "expected logout_callback to be a MFA tuple" 134 + end 135 + 114 136 opts 115 137 end 116 138 ··· 246 268 message: "OAuth issuer does not match PDS' authorization server", 247 269 reason: :issuer_mismatch 248 270 249 - _err -> 271 + err -> 272 + IO.inspect(err) 273 + 250 274 raise Atex.OAuth.Error, 251 275 message: "Failed to validate authorization code or token", 252 276 reason: :token_validation_failed 253 277 end 254 278 end 255 279 256 - # TODO: logout route 280 + get "/logout" do 281 + conn = fetch_session(conn) 282 + logout_callback = Keyword.get(conn.private.atex_oauth_opts, :logout_callback) 283 + 284 + conn = 285 + case OAuth.current_session_key(conn) do 286 + {:ok, session_key} -> 287 + case revoke_session(conn, session_key) do 288 + {:ok, conn} -> conn 289 + {:error, _} -> conn 290 + end 291 + 292 + :error -> 293 + conn 294 + end 295 + 296 + conn = Plug.Conn.clear_session(conn) 297 + 298 + if logout_callback do 299 + {mod, func, args} = logout_callback 300 + apply(mod, func, [conn | args]) 301 + else 302 + conn 303 + |> put_resp_header("location", "/") 304 + |> send_resp(302, "") 305 + end 306 + end 307 + 308 + @doc """ 309 + Revokes a session, removing it from the store and cleaning up the Plug session. 310 + 311 + This function: 312 + 1. Deletes the session from `Atex.OAuth.SessionStore` 313 + 2. Revokes tokens with the authorization server 314 + 3. Removes the session key from the Plug session's active session 315 + 4. If the deleted session was the active one, switches to another or clears it 316 + 317 + ## Parameters 318 + 319 + - `conn` - The Plug connection 320 + - `session_key` - The composite session key to revoke 321 + 322 + ## Returns 323 + 324 + - `{:ok, conn}` - Session revoked; the returned conn has updated session data 325 + - `{:error, :not_found}` - Session key not found 326 + 327 + """ 328 + @spec revoke_session(Plug.Conn.t(), String.t()) :: {:ok, Plug.Conn.t()} | {:error, :not_found} 329 + def revoke_session(%Plug.Conn{} = conn, session_key) do 330 + case OAuth.delete_session(session_key) do 331 + :ok -> 332 + session_keys = get_session(conn, @session_keys_name) || [] 333 + active_key = get_session(conn, @session_active_name) 334 + 335 + session_keys = List.delete(session_keys, session_key) 336 + 337 + conn = 338 + if active_key == session_key do 339 + new_active = List.first(session_keys) 340 + 341 + conn 342 + |> put_session(@session_active_name, new_active) 343 + |> put_session(@session_keys_name, session_keys) 344 + else 345 + put_session(conn, @session_keys_name, session_keys) 346 + end 347 + 348 + {:ok, conn} 349 + 350 + {:error, reason} -> 351 + {:error, reason} 352 + end 353 + end 257 354 end