diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cad07f..d7867f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,9 @@ and this project adheres to and writing to a JSON file. - Sigils for `Atex.AtURI` and `Atex.TID`, `~AT"at://..."` and `~TID"..."` respectively. +- `/logout` route for `Atex.OAuth.Plug` to revoke the current session, as well + as `Atex.OAuth.Plug.revoke_session/2` to revoke a conn's session + programmaticly (e.g. from a session management dashboard). ## [0.8.0] - 2026-03-29 diff --git a/examples/oauth.ex b/examples/oauth.ex index b62e2a3..35b017f 100644 --- a/examples/oauth.ex +++ b/examples/oauth.ex @@ -15,7 +15,12 @@ defmodule ExampleOAuthPlug do plug :match plug :dispatch - forward "/oauth", to: Atex.OAuth.Plug, init_opts: [callback: {__MODULE__, :oauth_callback, []}] + forward "/oauth", + to: Atex.OAuth.Plug, + init_opts: [ + callback: {__MODULE__, :oauth_callback, []}, + logout_callback: {__MODULE__, :logout_callback, []} + ] def oauth_callback(conn) do IO.inspect(conn, label: "callback from oauth!") @@ -26,12 +31,20 @@ defmodule ExampleOAuthPlug do |> send_resp() end + def logout_callback(conn) do + conn + |> put_resp_header("Location", "/") + |> resp(302, "") + |> send_resp() + end + get "/whoami" do conn = fetch_session(conn) case XRPC.OAuthClient.from_conn(conn) do {:ok, client} -> - send_resp(conn, 200, "hello #{client.did}") + did = XRPC.OAuthClient.did(client) + send_resp(conn, 200, "hello #{did}") :error -> send_resp(conn, 401, "Unauthorized") @@ -43,8 +56,8 @@ defmodule ExampleOAuthPlug do with {:ok, client} <- XRPC.OAuthClient.from_conn(conn), {:ok, response, client} <- - XRPC.post(client, %Com.Atproto.Repo.CreateRecord{ - input: %Com.Atproto.Repo.CreateRecord.Input{ + XRPC.post(client, "com.atproto.repo.createRecord", + json: %{ repo: client.did, collection: "app.bsky.feed.post", rkey: Atex.TID.now() |> to_string(), @@ -54,7 +67,7 @@ defmodule ExampleOAuthPlug do createdAt: DateTime.to_iso8601(DateTime.utc_now()) } } - }) do + ) do IO.inspect(response, label: "output") send_resp(conn, 200, response.body.uri) diff --git a/lib/atex/oauth.ex b/lib/atex/oauth.ex index 3a1050d..862aa4d 100644 --- a/lib/atex/oauth.ex +++ b/lib/atex/oauth.ex @@ -36,7 +36,8 @@ defmodule Atex.OAuth do issuer: String.t(), par_endpoint: String.t(), token_endpoint: String.t(), - authorization_endpoint: String.t() + authorization_endpoint: String.t(), + revocation_endpoint: String.t() } @type tokens() :: %{ @@ -71,7 +72,10 @@ defmodule Atex.OAuth do | {:redirect_uri, String.t()} | {:scopes, String.t()} + require Logger + alias Atex.Config.OAuth, as: Config + alias Atex.OAuth.{Session, SessionStore} @session_keys_name :atex_sessions @session_active_name :atex_active_session @@ -544,7 +548,8 @@ defmodule Atex.OAuth do "issuer" => metadata_issuer, "pushed_authorization_request_endpoint" => par_endpoint, "token_endpoint" => token_endpoint, - "authorization_endpoint" => authorization_endpoint + "authorization_endpoint" => authorization_endpoint, + "revocation_endpoint" => revocation_endpoint } }} -> if issuer != metadata_issuer do @@ -555,7 +560,8 @@ defmodule Atex.OAuth do issuer: metadata_issuer, par_endpoint: par_endpoint, token_endpoint: token_endpoint, - authorization_endpoint: authorization_endpoint + authorization_endpoint: authorization_endpoint, + revocation_endpoint: revocation_endpoint }} end @@ -607,6 +613,7 @@ defmodule Atex.OAuth do {:ok, body, dpop_nonce} {:ok, %{body: %{"error" => error, "error_description" => error_description}}} -> + IO.inspect(request) {:error, {:oauth_error, error, error_description}, dpop_nonce} {:ok, _} -> @@ -617,6 +624,8 @@ defmodule Atex.OAuth do end true -> + IO.inspect(request) + {:error, {:oauth_error, resp.body["error"], resp.body["error_description"]}, dpop_nonce} end @@ -682,9 +691,97 @@ defmodule Atex.OAuth do err end end + end + end - err -> - err + @doc """ + Revokes the access and refresh tokens with the authorization server. + + Sends both tokens to the revocation endpoint as defined in RFC 7009. + This invalidates the tokens on the PDS side, preventing further use. + + ## Parameters + + - `session` - The session containing tokens to revoke + - `authz_metadata` - Authorization server metadata including `revocation_endpoint` + + ## Returns + + - `:ok` - Tokens successfully revoked (or revocation endpoint unreachable) + - `{:error, reason}` - Revocation failed + + """ + @spec revoke_tokens(Session.t(), authorization_metadata()) :: :ok | {:error, any()} + def revoke_tokens(%Session{} = session, authz_metadata) do + client_id = Config.client_id() + + body = %{ + client_id: client_id, + token: session.refresh_token, + token_type_hint: "refresh_token" + } + + case Req.post(authz_metadata.revocation_endpoint, form: body) do + {:ok, %{status: status}} when status in [200, 204] -> + :ok + + {:ok, %{body: %{"error" => error}}} -> + Logger.warning("Token revocation failed: #{error}") + :ok + + {:error, reason} -> + Logger.warning("Token revocation request failed: #{inspect(reason)}") + :ok + + unexpected -> + Logger.warning("Unexpected token revocation response: #{inspect(unexpected)}") + :ok + end + end + + @doc """ + Deletes a session from the store and revokes its tokens. + + This is the primary function for logging out a session. It: + 1. Fetches the session data from the store if a key is provided + 2. Revokes the tokens with the authorization server + 3. Removes the session from the store + + ## Parameters + + - `session_or_key` - Either a `Session.t()` struct or a composite session key string + + ## Returns + + - `:ok` - Session deleted and tokens revoked + - `{:error, :not_found}` - Session not found in store + - `{:error, reason}` - Token revocation or store deletion failed + + ## Examples + + # Using a session key + case Atex.OAuth.delete_session("did:plc:abc123:device-nonce") do + :ok -> :logged_out + {:error, :not_found} -> :session_already_gone + end + + # Using a session struct + {:ok, session} = Atex.OAuth.SessionStore.get("did:plc:abc123:device-nonce") + :ok = Atex.OAuth.delete_session(session) + + """ + @spec delete_session(Session.t() | String.t()) :: :ok | {:error, :not_found | any()} + def delete_session(%Session{} = session) do + with {:ok, authz_metadata} <- get_authorization_server_metadata(session.iss, true), + :ok <- revoke_tokens(session, authz_metadata) do + SessionStore.delete(session) + end + end + + def delete_session(session_key) when is_binary(session_key) do + case SessionStore.get(session_key) do + {:ok, session} -> delete_session(session) + {:error, reason} -> {:error, reason} end end diff --git a/lib/atex/oauth/error.ex b/lib/atex/oauth/error.ex index b4a131f..82e5b85 100644 --- a/lib/atex/oauth/error.ex +++ b/lib/atex/oauth/error.ex @@ -22,4 +22,6 @@ defmodule Atex.OAuth.Error do """ defexception [:message, :reason] + + def message(exception), do: "#{exception.message}. reason: #{exception.reason}" end diff --git a/lib/atex/oauth/plug.ex b/lib/atex/oauth/plug.ex index c1ddb0f..3b54147 100644 --- a/lib/atex/oauth/plug.ex +++ b/lib/atex/oauth/plug.ex @@ -2,12 +2,13 @@ defmodule Atex.OAuth.Plug do @moduledoc """ Plug router for handling AT Protocol's OAuth flow. - This module provides three endpoints: + This module provides four endpoints: - `GET /login?handle=` - Initiates the OAuth authorization flow for a given handle - `GET /callback` - Handles the OAuth callback after user authorization - `GET /client-metadata.json` - Serves the OAuth client metadata + - `GET /logout` - Logs out the current session and revokes tokens ## Usage @@ -19,6 +20,9 @@ defmodule Atex.OAuth.Plug do Function, Args). This callback is invoked after successful OAuth authentication, receiving the connection with the authenticated session data. + An optional `:logout_callback` option can be provided for handling logout + redirects. If not provided, the user is redirected to "/". + ## Error Handling `Atex.OAuth.Error` exceptions are raised when errors occur during the OAuth @@ -29,7 +33,7 @@ defmodule Atex.OAuth.Plug do ## Example Example implementation showing how to set up the OAuth plug with proper - session handling, error handling, and a callback function. + session handling, error handling, and callbacks. defmodule ExampleOAuthPlug do use Plug.Router @@ -45,7 +49,11 @@ defmodule Atex.OAuth.Plug do plug :match plug :dispatch - forward "/oauth", to: Atex.OAuth.Plug, init_opts: [callback: {__MODULE__, :oauth_callback, []}] + forward "/oauth", to: Atex.OAuth.Plug, + init_opts: [ + callback: {__MODULE__, :oauth_callback, []}, + logout_callback: {__MODULE__, :logout_callback, []} + ] def oauth_callback(conn) do # Handle successful OAuth authentication @@ -55,6 +63,14 @@ defmodule Atex.OAuth.Plug do |> send_resp() end + def logout_callback(conn) do + # Handle logout redirect + conn + |> put_resp_header("Location", "/login") + |> resp(307, "") + |> send_resp() + end + def put_secret_key_base(conn, _) do put_in( conn.secret_key_base, @@ -111,6 +127,12 @@ defmodule Atex.OAuth.Plug do raise "expected callback to be a MFA tuple" end + logout_callback = Keyword.get(opts, :logout_callback, nil) + + if logout_callback && !match?({_module, _function, _args}, logout_callback) do + raise "expected logout_callback to be a MFA tuple" + end + opts end @@ -246,12 +268,87 @@ defmodule Atex.OAuth.Plug do message: "OAuth issuer does not match PDS' authorization server", reason: :issuer_mismatch - _err -> + err -> + IO.inspect(err) + raise Atex.OAuth.Error, message: "Failed to validate authorization code or token", reason: :token_validation_failed end end - # TODO: logout route + get "/logout" do + conn = fetch_session(conn) + logout_callback = Keyword.get(conn.private.atex_oauth_opts, :logout_callback) + + conn = + case OAuth.current_session_key(conn) do + {:ok, session_key} -> + case revoke_session(conn, session_key) do + {:ok, conn} -> conn + {:error, _} -> conn + end + + :error -> + conn + end + + conn = Plug.Conn.clear_session(conn) + + if logout_callback do + {mod, func, args} = logout_callback + apply(mod, func, [conn | args]) + else + conn + |> put_resp_header("location", "/") + |> send_resp(302, "") + end + end + + @doc """ + Revokes a session, removing it from the store and cleaning up the Plug session. + + This function: + 1. Deletes the session from `Atex.OAuth.SessionStore` + 2. Revokes tokens with the authorization server + 3. Removes the session key from the Plug session's active session + 4. If the deleted session was the active one, switches to another or clears it + + ## Parameters + + - `conn` - The Plug connection + - `session_key` - The composite session key to revoke + + ## Returns + + - `{:ok, conn}` - Session revoked; the returned conn has updated session data + - `{:error, :not_found}` - Session key not found + + """ + @spec revoke_session(Plug.Conn.t(), String.t()) :: {:ok, Plug.Conn.t()} | {:error, :not_found} + def revoke_session(%Plug.Conn{} = conn, session_key) do + case OAuth.delete_session(session_key) do + :ok -> + session_keys = get_session(conn, @session_keys_name) || [] + active_key = get_session(conn, @session_active_name) + + session_keys = List.delete(session_keys, session_key) + + conn = + if active_key == session_key do + new_active = List.first(session_keys) + + conn + |> put_session(@session_active_name, new_active) + |> put_session(@session_keys_name, session_keys) + else + put_session(conn, @session_keys_name, session_keys) + end + + {:ok, conn} + + {:error, reason} -> + {:error, reason} + end + end end