···2828 and writing to a JSON file.
2929- Sigils for `Atex.AtURI` and `Atex.TID`, `~AT"at://..."` and `~TID"..."`
3030 respectively.
3131+- `/logout` route for `Atex.OAuth.Plug` to revoke the current session, as well
3232+ as `Atex.OAuth.Plug.revoke_session/2` to revoke a conn's session
3333+ programmaticly (e.g. from a session management dashboard).
31343235## [0.8.0] - 2026-03-29
3336
···22 @moduledoc """
33 Plug router for handling AT Protocol's OAuth flow.
4455- This module provides three endpoints:
55+ This module provides four endpoints:
6677 - `GET /login?handle=<handle>` - Initiates the OAuth authorization flow for a
88 given handle
99 - `GET /callback` - Handles the OAuth callback after user authorization
1010 - `GET /client-metadata.json` - Serves the OAuth client metadata
1111+ - `GET /logout` - Logs out the current session and revokes tokens
11121213 ## Usage
1314···1920 Function, Args). This callback is invoked after successful OAuth
2021 authentication, receiving the connection with the authenticated session data.
21222323+ An optional `:logout_callback` option can be provided for handling logout
2424+ redirects. If not provided, the user is redirected to "/".
2525+2226 ## Error Handling
23272428 `Atex.OAuth.Error` exceptions are raised when errors occur during the OAuth
···2933 ## Example
30343135 Example implementation showing how to set up the OAuth plug with proper
3232- session handling, error handling, and a callback function.
3636+ session handling, error handling, and callbacks.
33373438 defmodule ExampleOAuthPlug do
3539 use Plug.Router
···4549 plug :match
4650 plug :dispatch
47514848- forward "/oauth", to: Atex.OAuth.Plug, init_opts: [callback: {__MODULE__, :oauth_callback, []}]
5252+ forward "/oauth", to: Atex.OAuth.Plug,
5353+ init_opts: [
5454+ callback: {__MODULE__, :oauth_callback, []},
5555+ logout_callback: {__MODULE__, :logout_callback, []}
5656+ ]
49575058 def oauth_callback(conn) do
5159 # Handle successful OAuth authentication
···5563 |> send_resp()
5664 end
57656666+ def logout_callback(conn) do
6767+ # Handle logout redirect
6868+ conn
6969+ |> put_resp_header("Location", "/login")
7070+ |> resp(307, "")
7171+ |> send_resp()
7272+ end
7373+5874 def put_secret_key_base(conn, _) do
5975 put_in(
6076 conn.secret_key_base,
···111127 raise "expected callback to be a MFA tuple"
112128 end
113129130130+ logout_callback = Keyword.get(opts, :logout_callback, nil)
131131+132132+ if logout_callback && !match?({_module, _function, _args}, logout_callback) do
133133+ raise "expected logout_callback to be a MFA tuple"
134134+ end
135135+114136 opts
115137 end
116138···246268 message: "OAuth issuer does not match PDS' authorization server",
247269 reason: :issuer_mismatch
248270249249- _err ->
271271+ err ->
272272+ IO.inspect(err)
273273+250274 raise Atex.OAuth.Error,
251275 message: "Failed to validate authorization code or token",
252276 reason: :token_validation_failed
253277 end
254278 end
255279256256- # TODO: logout route
280280+ get "/logout" do
281281+ conn = fetch_session(conn)
282282+ logout_callback = Keyword.get(conn.private.atex_oauth_opts, :logout_callback)
283283+284284+ conn =
285285+ case OAuth.current_session_key(conn) do
286286+ {:ok, session_key} ->
287287+ case revoke_session(conn, session_key) do
288288+ {:ok, conn} -> conn
289289+ {:error, _} -> conn
290290+ end
291291+292292+ :error ->
293293+ conn
294294+ end
295295+296296+ conn = Plug.Conn.clear_session(conn)
297297+298298+ if logout_callback do
299299+ {mod, func, args} = logout_callback
300300+ apply(mod, func, [conn | args])
301301+ else
302302+ conn
303303+ |> put_resp_header("location", "/")
304304+ |> send_resp(302, "")
305305+ end
306306+ end
307307+308308+ @doc """
309309+ Revokes a session, removing it from the store and cleaning up the Plug session.
310310+311311+ This function:
312312+ 1. Deletes the session from `Atex.OAuth.SessionStore`
313313+ 2. Revokes tokens with the authorization server
314314+ 3. Removes the session key from the Plug session's active session
315315+ 4. If the deleted session was the active one, switches to another or clears it
316316+317317+ ## Parameters
318318+319319+ - `conn` - The Plug connection
320320+ - `session_key` - The composite session key to revoke
321321+322322+ ## Returns
323323+324324+ - `{:ok, conn}` - Session revoked; the returned conn has updated session data
325325+ - `{:error, :not_found}` - Session key not found
326326+327327+ """
328328+ @spec revoke_session(Plug.Conn.t(), String.t()) :: {:ok, Plug.Conn.t()} | {:error, :not_found}
329329+ def revoke_session(%Plug.Conn{} = conn, session_key) do
330330+ case OAuth.delete_session(session_key) do
331331+ :ok ->
332332+ session_keys = get_session(conn, @session_keys_name) || []
333333+ active_key = get_session(conn, @session_active_name)
334334+335335+ session_keys = List.delete(session_keys, session_key)
336336+337337+ conn =
338338+ if active_key == session_key do
339339+ new_active = List.first(session_keys)
340340+341341+ conn
342342+ |> put_session(@session_active_name, new_active)
343343+ |> put_session(@session_keys_name, session_keys)
344344+ else
345345+ put_session(conn, @session_keys_name, session_keys)
346346+ end
347347+348348+ {:ok, conn}
349349+350350+ {:error, reason} ->
351351+ {:error, reason}
352352+ end
353353+ end
257354end