diff --git a/TODO.md b/TODO.md index c70522c..172f887 100644 --- a/TODO.md +++ b/TODO.md @@ -1,8 +1 @@ # Parking Lot (TODO) - -- Add OAuth token introspection: - - expose `/oauth/introspect` - - return inactive for missing, revoked, rotated, expired, or malformed tokens - - return active token metadata for valid OAuth access or refresh tokens - - require client authentication and verify the requester is allowed to inspect - the token diff --git a/docs/reference/oauth.md b/docs/reference/oauth.md index 604d52c..968b60a 100644 --- a/docs/reference/oauth.md +++ b/docs/reference/oauth.md @@ -119,7 +119,7 @@ flow, or prompt handling. Cocoon, Tranquil, ZDS, and Tempest issue access and refresh tokens and support refresh. ZDS and Tranquil expose introspection and revocation. Tempest exposes -revocation but not introspection. +both revocation and token introspection. Tempest stores token material as hashes, rotates refresh tokens, rejects reused or revoked refresh rows, and signs access tokens as Phoenix tokens backed by @@ -257,8 +257,8 @@ requires the same client ID, rotates refresh tokens, and rejects already rotated or revoked rows. Access tokens are Phoenix tokens backed by database token rows, with hashed access and refresh token storage. -Tempest does not support loopback development client metadata, private-use -redirect schemes, or token introspection. +Tempest does not support private-use redirect schemes. Loopback development +client metadata and token introspection are supported for the local OAuth flow. Tempest can issue DPoP-bound OAuth tokens through PAR and PKCE, but it still implements a smaller client model than Cocoon, Tranquil, or ZDS. diff --git a/lib/tempest/oauth.ex b/lib/tempest/oauth.ex index dcc5c52..ac4b259 100644 --- a/lib/tempest/oauth.ex +++ b/lib/tempest/oauth.ex @@ -186,6 +186,24 @@ defmodule Tempest.OAuth do :ok end + @doc """ + Introspects an OAuth access or refresh token for the requesting client. + + Invalid, expired, revoked, rotated, malformed, missing, or cross-client tokens + return an inactive response after the requesting client has authenticated using + its registered client authentication method. + """ + def introspect(params, public_url) when is_map(params) and is_binary(public_url) do + with :ok <- require_param(params, "client_id") do + token = Map.get(params, "token") + client_id = params["client_id"] + + introspect_token(token, client_id, params, public_url) + end + end + + def introspect(_params, _public_url), do: {:error, :invalid_request} + @doc """ Signs a short-lived OAuth access token for an issued OAuth token row. """ @@ -224,6 +242,87 @@ defmodule Tempest.OAuth do def verify_access_token(_token), do: {:error, :invalid_token} + defp introspect_token(token, client_id, params, public_url) when is_binary(token) and token != "" do + case active_access_token(token) || active_refresh_token(token) do + {:ok, account, oauth_token, token_metadata} -> + introspect_active_token(account, oauth_token, token_metadata, client_id, params, public_url) + + nil -> + with :ok <- verify_introspection_client(params, public_url) do + {:ok, inactive_token_metadata()} + end + end + end + + defp introspect_token(_token, _client_id, params, public_url) do + with :ok <- verify_introspection_client(params, public_url) do + {:ok, inactive_token_metadata()} + end + end + + defp active_access_token(token) do + case verify_access_token(token) do + {:ok, account, oauth_token, _claims} -> + {:ok, account, oauth_token, %{"exp" => DateTime.to_unix(oauth_token.expires_at)}} + + {:error, _reason} -> + nil + end + end + + defp active_refresh_token(refresh_token) do + Token + |> where([t], t.refresh_token_hash == ^hash(refresh_token)) + |> where([t], is_nil(t.revoked_at)) + |> where([t], is_nil(t.rotated_at)) + |> preload(:account) + |> Repo.one() + |> case do + %Token{account: %Account{} = account} = oauth_token -> + if active_account?(account) and Tempest.Identity.Correctness.check_local(account) == :ok do + {:ok, account, oauth_token, %{}} + end + + _missing -> + nil + end + end + + defp introspect_active_token(account, oauth_token, token_metadata, client_id, params, public_url) do + with :ok <- verify_introspection_client(oauth_token, client_id, params, public_url) do + {:ok, + token_metadata + |> Map.merge(%{ + "active" => true, + "client_id" => oauth_token.client_id, + "scope" => oauth_token.scope, + "sub" => account.did, + "token_type" => "DPoP", + "cnf" => %{"jkt" => oauth_token.dpop_jkt} + })} + end + end + + defp verify_introspection_client(%Token{client_id: client_id} = token, client_id, params, public_url), + do: verify_client_auth(token, params, public_url) + + defp verify_introspection_client(%Token{}, _client_id, params, public_url) do + with :ok <- verify_introspection_client(params, public_url) do + {:ok, inactive_token_metadata()} + end + end + + defp verify_introspection_client(params, public_url) do + with {:ok, client} <- ClientMetadata.fetch(params["client_id"]), + {:ok, _client_auth} <- ClientAssertionVerifier.verify(client, params, public_url) do + :ok + else + {:error, reason} -> {:error, reason} + end + end + + defp inactive_token_metadata, do: %{"active" => false} + defp issue_tokens_from_code(%AuthorizationCode{} = code, proof) do now = now() @@ -365,11 +464,13 @@ defmodule Tempest.OAuth do token.revoked_at -> {:error, :invalid_token} token.access_token_hash != hash(access_token) -> {:error, :invalid_token} expired?(token.expires_at, now) -> {:error, :expired_token} - not account.active or account.status != "active" -> {:error, :inactive_account} + not active_account?(account) -> {:error, :inactive_account} true -> :ok end end + defp active_account?(%Account{} = account), do: account.active and account.status == "active" + defp authenticate_account(identifier, password) when is_binary(identifier) and is_binary(password) do normalized = identifier |> String.trim() |> String.downcase() diff --git a/lib/tempest/oauth/metadata.ex b/lib/tempest/oauth/metadata.ex index d138e7f..6c67b00 100644 --- a/lib/tempest/oauth/metadata.ex +++ b/lib/tempest/oauth/metadata.ex @@ -37,6 +37,8 @@ defmodule Tempest.OAuth.Metadata do "issuer" => base_url, "authorization_endpoint" => base_url <> "/oauth/authorize", "token_endpoint" => base_url <> "/oauth/token", + "introspection_endpoint" => base_url <> "/oauth/introspect", + "revocation_endpoint" => base_url <> "/oauth/revoke", "jwks_uri" => base_url <> "/oauth/jwks", "pushed_authorization_request_endpoint" => base_url <> "/oauth/par", "require_pushed_authorization_requests" => true, diff --git a/lib/tempest_web/controllers/oauth_controller.ex b/lib/tempest_web/controllers/oauth_controller.ex index 293f673..ae3b557 100644 --- a/lib/tempest_web/controllers/oauth_controller.ex +++ b/lib/tempest_web/controllers/oauth_controller.ex @@ -122,6 +122,29 @@ defmodule TempestWeb.OAuthController do end end + def introspect(conn, params) do + public_url = Tempest.Config.load!().public_url + + result = + with :ok <- RateLimiter.check(:oauth, Map.get(params, "client_id", "unknown")) do + OAuth.introspect(params, public_url) + end + + case result do + {:ok, response} -> + json(conn, response) + + {:error, :rate_limited} -> + conn |> put_status(429) |> json(%{"error" => "RateLimitExceeded"}) + + {:error, :invalid_client} -> + conn |> put_status(400) |> json(%{"error" => "invalid_client"}) + + {:error, _reason} -> + conn |> put_status(400) |> json(%{"error" => "invalid_request"}) + end + end + defp authorization_page(par, error) do escaped_client = Phoenix.HTML.html_escape(par.client_id) |> Phoenix.HTML.safe_to_string() escaped_scope = Phoenix.HTML.html_escape(par.scope) |> Phoenix.HTML.safe_to_string() diff --git a/lib/tempest_web/router.ex b/lib/tempest_web/router.ex index c5c9f38..fef2626 100644 --- a/lib/tempest_web/router.ex +++ b/lib/tempest_web/router.ex @@ -92,6 +92,7 @@ defmodule TempestWeb.Router do post "/par", OAuthController, :par post "/token", OAuthController, :token post "/revoke", OAuthController, :revoke + post "/introspect", OAuthController, :introspect end scope "/xrpc", TempestWeb do diff --git a/test/smoke/oauth-security.hurl b/test/smoke/oauth-security.hurl index e6e1e10..8744f73 100644 --- a/test/smoke/oauth-security.hurl +++ b/test/smoke/oauth-security.hurl @@ -46,3 +46,11 @@ POST {{base_url}}/oauth/revoke token: not-a-real-token client_id: did:web:client.example.com HTTP 200 + +POST {{base_url}}/oauth/introspect +[FormParams] +token: not-a-real-token +client_id: http://localhost +HTTP 200 +[Asserts] +jsonpath "$.active" == false diff --git a/test/tempest_web/controllers/oauth_flow_test.exs b/test/tempest_web/controllers/oauth_flow_test.exs index cfa2e62..b435f0a 100644 --- a/test/tempest_web/controllers/oauth_flow_test.exs +++ b/test/tempest_web/controllers/oauth_flow_test.exs @@ -84,6 +84,34 @@ defmodule TempestWeb.OAuthFlowTest do assert token_response["sub"] == account["did"] assert is_binary(token_response["access_token"]) assert is_binary(token_response["refresh_token"]) + account_did = account["did"] + + introspect_access_conn = + conn + |> recycle() + |> post(~p"/oauth/introspect", %{"client_id" => @client_id, "token" => token_response["access_token"]}) + + assert %{ + "active" => true, + "client_id" => @client_id, + "scope" => "atproto", + "sub" => ^account_did, + "token_type" => "DPoP", + "cnf" => %{"jkt" => _jkt}, + "exp" => exp + } = json_response(introspect_access_conn, 200) + + assert is_integer(exp) + + cross_client_conn = + conn + |> recycle() + |> post(~p"/oauth/introspect", %{ + "client_id" => "http://localhost?redirect_uri=http://127.0.0.1/cb", + "token" => token_response["access_token"] + }) + + assert %{"active" => false} = json_response(cross_client_conn, 200) refresh_conn = conn @@ -105,12 +133,47 @@ defmodule TempestWeb.OAuthFlowTest do refute refreshed_response["access_token"] == token_response["access_token"] refute refreshed_response["refresh_token"] == token_response["refresh_token"] + introspect_rotated_refresh_conn = + conn + |> recycle() + |> post(~p"/oauth/introspect", %{"client_id" => @client_id, "token" => token_response["refresh_token"]}) + + assert %{"active" => false} = json_response(introspect_rotated_refresh_conn, 200) + + introspect_refresh_conn = + conn + |> recycle() + |> post(~p"/oauth/introspect", %{"client_id" => @client_id, "token" => refreshed_response["refresh_token"]}) + + assert %{ + "active" => true, + "client_id" => @client_id, + "scope" => "atproto", + "sub" => ^account_did, + "token_type" => "DPoP", + "cnf" => %{"jkt" => _refresh_jkt} + } = json_response(introspect_refresh_conn, 200) + + unknown_token_conn = + conn + |> recycle() + |> post(~p"/oauth/introspect", %{"client_id" => @client_id, "token" => "not-a-real-token"}) + + assert %{"active" => false} = json_response(unknown_token_conn, 200) + revoke_conn = conn |> recycle() |> post(~p"/oauth/revoke", %{"token" => refreshed_response["access_token"], "client_id" => @client_id}) assert response(revoke_conn, 200) == "" + + introspect_revoked_access_conn = + conn + |> recycle() + |> post(~p"/oauth/introspect", %{"client_id" => @client_id, "token" => refreshed_response["access_token"]}) + + assert %{"active" => false} = json_response(introspect_revoked_access_conn, 200) end test "private_key_jwt client auth works for PAR, token exchange, and refresh", %{conn: conn, account: account} do @@ -218,6 +281,34 @@ defmodule TempestWeb.OAuthFlowTest do refreshed_response = json_response(refresh_conn, 200) assert refreshed_response["token_type"] == "DPoP" + account_did = account["did"] + + introspect_conn = + conn + |> recycle() + |> post(~p"/oauth/introspect", %{ + "client_id" => @client_id, + "token" => refreshed_response["refresh_token"], + "client_assertion_type" => client_assertion_type(), + "client_assertion" => client_assertion(client_key, "introspect-jti") + }) + + assert %{ + "active" => true, + "client_id" => @client_id, + "scope" => "atproto", + "sub" => ^account_did + } = json_response(introspect_conn, 200) + + missing_introspection_auth_conn = + conn + |> recycle() + |> post(~p"/oauth/introspect", %{ + "client_id" => @client_id, + "token" => refreshed_response["refresh_token"] + }) + + assert %{"error" => "invalid_client"} = json_response(missing_introspection_auth_conn, 400) replay_refresh_conn = conn diff --git a/test/tempest_web/controllers/oauth_metadata_controller_test.exs b/test/tempest_web/controllers/oauth_metadata_controller_test.exs index d27c43f..34a5869 100644 --- a/test/tempest_web/controllers/oauth_metadata_controller_test.exs +++ b/test/tempest_web/controllers/oauth_metadata_controller_test.exs @@ -35,6 +35,8 @@ defmodule TempestWeb.OAuthMetadataControllerTest do "issuer" => "http://localhost:4002", "authorization_endpoint" => "http://localhost:4002/oauth/authorize", "token_endpoint" => "http://localhost:4002/oauth/token", + "introspection_endpoint" => "http://localhost:4002/oauth/introspect", + "revocation_endpoint" => "http://localhost:4002/oauth/revoke", "jwks_uri" => "http://localhost:4002/oauth/jwks", "pushed_authorization_request_endpoint" => "http://localhost:4002/oauth/par", "require_pushed_authorization_requests" => true,