diff --git a/config/runtime.exs b/config/runtime.exs index 91f056353d27..4596d3fb359b 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -840,7 +840,9 @@ cloud_cron = [ # Daily at 4:00 UTC {"0 4 * * *", Plausible.Workers.SetLegacyTimeOnPageCutoff}, # Daily at 2:00 UTC - {"0 2 * * *", Plausible.Workers.ScoreTrialProspects} + {"0 2 * * *", Plausible.Workers.ScoreTrialProspects}, + # Every 10 minutes + {"*/10 * * * *", Plausible.Workers.OAuthCleanup} ] crontab = if(is_selfhost, do: base_cron, else: base_cron ++ cloud_cron) @@ -860,7 +862,8 @@ base_queues = [ domain_change_transition: 1, check_accept_traffic_until: 1, clickhouse_clean_sites: 1, - locations_sync: 1 + locations_sync: 1, + oauth_cleanup: 1 ] cloud_queues = [ diff --git a/lib/plausible/oauth.ex b/lib/plausible/oauth.ex new file mode 100644 index 000000000000..7336d87d8073 --- /dev/null +++ b/lib/plausible/oauth.ex @@ -0,0 +1,511 @@ +defmodule Plausible.OAuth do + @moduledoc """ + Minimal, hand-rolled OAuth 2.1 authorization server used to authenticate + remote MCP connectors (e.g. Claude) against Plausible. + + Client registration is **CIMD-only** (Client ID Metadata Documents): the + `client_id` is an HTTPS URL pointing to a JSON metadata document that the + authorization server fetches at authorize-time. There is no Dynamic Client + Registration endpoint and no clients table. + + Security invariants enforced here: + + * PKCE with `S256` is mandatory (`plain` is rejected). + * Authorization codes are single-use and short-lived. + * The `resource` (RFC 8707) is threaded from authorize -> code -> token -> + access token so that tokens are audience-bound. + * Only hashes of codes/tokens are persisted (see `Plausible.OAuth.Token`). + * The CIMD fetch never happens over an unguarded HTTP client - see + `fetch_client_metadata/1`. + """ + + use Plausible.Repo + + alias Plausible.OAuth.{AccessToken, AuthorizationCode, Token} + + # TTLs (in seconds) + @authorization_code_ttl 600 + @access_token_ttl 3600 + @refresh_token_ttl 60 * 60 * 24 * 30 + + @supported_scopes ["stats:read:*", "sites:read:*"] + + # CIMD fetch limits (the SSRF-safe client owns IP/redirect protection). + @fetch_timeout 5_000 + @max_metadata_bytes 1_000_000 + + @type token_response() :: %{ + access_token: String.t(), + token_type: String.t(), + expires_in: pos_integer(), + refresh_token: String.t(), + scope: String.t() + } + + @spec supported_scopes() :: [String.t()] + def supported_scopes(), do: @supported_scopes + + @spec access_token_ttl() :: pos_integer() + def access_token_ttl(), do: @access_token_ttl + + @doc """ + Normalizes a space-delimited `scope` request parameter against the supported + scopes. + + An empty/absent request defaults to all supported scopes (RFC 6749 §3.3 + pre-defined default). A non-empty request must name only supported scopes; + if it names any scope we don't support, the whole request is rejected with + `{:error, :invalid_scope}` (RFC 6749 §4.1.2.1) rather than silently dropping + the unsupported entries. The returned scopes follow the canonical + `supported_scopes/0` order regardless of the request order. + """ + @spec normalize_scopes(String.t() | nil) :: {:ok, [String.t()]} | {:error, :invalid_scope} + def normalize_scopes(scope) do + requested = (scope || "") |> String.split(" ", trim: true) |> MapSet.new() + supported = MapSet.new(@supported_scopes) + + cond do + MapSet.size(requested) == 0 -> {:ok, @supported_scopes} + not MapSet.subset?(requested, supported) -> {:error, :invalid_scope} + true -> {:ok, Enum.filter(@supported_scopes, &MapSet.member?(requested, &1))} + end + end + + ## Authorization codes + + @doc """ + Creates a single-use authorization code bound to the given user and team. + + `attrs` must include `:client_id`, `:redirect_uri`, `:code_challenge`, + `:code_challenge_method`, `:scopes` and (optionally) `:resource`. Returns the + raw code to be handed back to the client via the redirect. + """ + @spec create_authorization_code(Plausible.Auth.User.t(), Plausible.Teams.Team.t() | nil, map()) :: + {:ok, String.t()} | {:error, Ecto.Changeset.t()} + def create_authorization_code(user, team, attrs) do + code = Token.generate(:code) + + changeset = + AuthorizationCode.changeset(%{ + code_hash: code.hash, + client_id: attrs.client_id, + client_name: attrs[:client_name], + redirect_uri: attrs.redirect_uri, + code_challenge: attrs.code_challenge, + code_challenge_method: attrs.code_challenge_method, + scopes: attrs.scopes, + resource: attrs[:resource], + user_id: user.id, + team_id: team && team.id, + expires_at: DateTime.add(DateTime.utc_now(), @authorization_code_ttl, :second) + }) + + case Repo.insert(changeset) do + {:ok, _} -> {:ok, code.raw} + {:error, _} = error -> error + end + end + + @doc """ + Atomically consumes an authorization code (single-use: the row is deleted on + lookup regardless of subsequent validation) and validates it against the + presented PKCE verifier, redirect URI and resource. + """ + @spec consume_authorization_code( + String.t(), + String.t() | nil, + String.t() | nil, + String.t() | nil + ) :: + {:ok, AuthorizationCode.t()} | {:error, atom()} + def consume_authorization_code(code, verifier, redirect_uri, resource) do + with {:ok, auth_code} <- delete_and_fetch_code(Token.hash(code)), + :ok <- check_not_expired(auth_code.expires_at), + :ok <- verify_pkce(auth_code.code_challenge, verifier, auth_code.code_challenge_method), + :ok <- match(auth_code.redirect_uri, redirect_uri), + :ok <- match(auth_code.resource, resource) do + {:ok, auth_code} + end + end + + defp delete_and_fetch_code(code_hash) do + query = from(c in AuthorizationCode, where: c.code_hash == ^code_hash, select: c) + + case Repo.delete_all(query) do + {1, [code]} -> {:ok, code} + {0, _} -> {:error, :invalid_grant} + end + end + + ## Tokens + + @doc """ + Issues a fresh access/refresh token pair from a consumed authorization code. + """ + @spec issue_tokens(AuthorizationCode.t()) :: + {:ok, token_response()} | {:error, Ecto.Changeset.t()} + def issue_tokens(%AuthorizationCode{} = auth_code) do + now = DateTime.utc_now() + access = Token.generate(:access) + refresh = Token.generate(:refresh) + + changeset = + AccessToken.changeset(%{ + access_token_hash: access.hash, + access_token_prefix: access.prefix, + refresh_token_hash: refresh.hash, + refresh_token_prefix: refresh.prefix, + client_id: auth_code.client_id, + client_name: auth_code.client_name, + scopes: auth_code.scopes, + resource: auth_code.resource, + user_id: auth_code.user_id, + team_id: auth_code.team_id, + access_token_expires_at: DateTime.add(now, @access_token_ttl, :second), + refresh_token_expires_at: DateTime.add(now, @refresh_token_ttl, :second) + }) + + case Repo.insert(changeset) do + {:ok, _} -> {:ok, token_response(access.raw, refresh.raw, auth_code.scopes)} + {:error, _} = error -> error + end + end + + @doc """ + Rotates a refresh token: the presented refresh token is invalidated and a new + access/refresh pair is issued on the same row. Rotation is atomic, so a refresh + token can only be redeemed once. + """ + @spec refresh_tokens(String.t()) :: {:ok, token_response()} | {:error, atom()} + def refresh_tokens(raw_refresh) do + now = DateTime.utc_now() + access = Token.generate(:access) + refresh = Token.generate(:refresh) + + query = + from(t in AccessToken, + where: + t.refresh_token_hash == ^Token.hash(raw_refresh) and + t.refresh_token_expires_at > ^now, + select: t + ) + + updates = [ + set: [ + access_token_hash: access.hash, + access_token_prefix: access.prefix, + refresh_token_hash: refresh.hash, + refresh_token_prefix: refresh.prefix, + access_token_expires_at: DateTime.add(now, @access_token_ttl, :second), + refresh_token_expires_at: DateTime.add(now, @refresh_token_ttl, :second), + updated_at: now + ] + ] + + case Repo.update_all(query, updates) do + {1, [row]} -> {:ok, token_response(access.raw, refresh.raw, row.scopes)} + {0, _} -> {:error, :invalid_grant} + end + end + + @doc """ + Looks up a valid (non-expired) access token by its raw value, preloading the + bound user and team. Mirrors `Plausible.Auth.find_api_key/1`. + """ + @spec find_access_token(String.t()) :: {:ok, AccessToken.t()} | {:error, :invalid_token} + def find_access_token(raw_access) do + query = + from(t in AccessToken, + where: + t.access_token_hash == ^Token.hash(raw_access) and + t.access_token_expires_at > ^DateTime.utc_now(), + preload: [:user, :team] + ) + + case Repo.one(query) do + nil -> {:error, :invalid_token} + token -> {:ok, token} + end + end + + ## Grants management (user-facing "Connected applications") + + # Only refresh last_used_at at most once per this many seconds, to avoid a + # write on every single MCP request. + @last_used_throttle_seconds 60 + + @doc """ + Lists a user's currently-usable OAuth grants (those whose access or refresh + token has not yet expired), most recent first, with the bound team preloaded. + """ + @spec list_grants(Plausible.Auth.User.t()) :: [AccessToken.t()] + def list_grants(user) do + now = DateTime.utc_now() + + Repo.all( + from(t in AccessToken, + where: + t.user_id == ^user.id and + (t.access_token_expires_at > ^now or t.refresh_token_expires_at > ^now), + order_by: [desc: t.inserted_at], + preload: [:team] + ) + ) + end + + @doc """ + Revokes all grants a user holds that are bound to the given team. + + Called when a user loses access to a team (removed or leaves) so their MCP + tokens are invalidated immediately rather than lingering until expiry. Returns + the number of grants revoked. + """ + @spec revoke_grants_for_team_member(Plausible.Auth.User.t(), Plausible.Teams.Team.t()) :: + non_neg_integer() + def revoke_grants_for_team_member(user, team) do + {count, _} = + Repo.delete_all( + from(t in AccessToken, where: t.user_id == ^user.id and t.team_id == ^team.id) + ) + + count + end + + @doc """ + Revokes (deletes) a grant by id, scoped to the owning user. Removing the row + invalidates both the access and refresh tokens immediately. + """ + @spec revoke_grant(Plausible.Auth.User.t(), integer() | String.t()) :: + :ok | {:error, :not_found} + def revoke_grant(user, id) do + query = from(t in AccessToken, where: t.id == ^id and t.user_id == ^user.id) + + case Repo.delete_all(query) do + {n, _} when n > 0 -> :ok + {0, _} -> {:error, :not_found} + end + end + + @doc """ + Records that an access token was just used, throttled to at most once per + minute to avoid a write on every request. + """ + @spec mark_used(AccessToken.t()) :: :ok + def mark_used(%AccessToken{id: id}) do + now = DateTime.utc_now() + cutoff = DateTime.add(now, -@last_used_throttle_seconds, :second) + + Repo.update_all( + from(t in AccessToken, + where: t.id == ^id and (is_nil(t.last_used_at) or t.last_used_at < ^cutoff) + ), + set: [last_used_at: now] + ) + + :ok + end + + defp token_response(access_raw, refresh_raw, scopes) do + %{ + access_token: access_raw, + token_type: "Bearer", + expires_in: @access_token_ttl, + refresh_token: refresh_raw, + scope: Enum.join(scopes, " ") + } + end + + ## PKCE + + @doc """ + Verifies a PKCE code verifier against a stored challenge. Only `S256` is + accepted; `plain` and unknown methods are rejected. + """ + @spec verify_pkce(String.t(), String.t() | nil, String.t()) :: :ok | {:error, :invalid_grant} + def verify_pkce(challenge, verifier, "S256") + when is_binary(challenge) and is_binary(verifier) do + computed = :crypto.hash(:sha256, verifier) |> Base.url_encode64(padding: false) + + if Plug.Crypto.secure_compare(computed, challenge) do + :ok + else + {:error, :invalid_grant} + end + end + + def verify_pkce(_challenge, _verifier, _method), do: {:error, :invalid_grant} + + ## Client ID Metadata Documents (CIMD) + + @doc """ + Fetches and validates a Client ID Metadata Document. + + The `client_id` MUST be the HTTPS URL of the document; the fetched document is + validated to be self-referential (its `client_id` equals the requested URL) and + to declare at least one `redirect_uris` entry. + + ## SSRF + + This performs a server-side GET against an attacker-influenced URL, which is an + SSRF surface. IP/host filtering (private/reserved/loopback/link-local + DNS + resolution, IP pinning, and per-hop redirect re-validation) is delegated to the + shared `Plausible.SSRF` helper rather than hand-rolled here. Tests may inject a + fetcher via `config :plausible, Plausible.OAuth, client_metadata_fetcher: ...` + (a `module` exposing `get/1`, or a 1-arity function); otherwise the SSRF-safe + client is used. The whole MCP surface is additionally gated by the + off-by-default `:mcp_server` flag. + """ + @spec fetch_client_metadata(String.t()) :: {:ok, map()} | {:error, atom() | Exception.t()} + def fetch_client_metadata(client_id) do + with :ok <- validate_https(client_id), + {:ok, body} <- http_get(client_id), + {:ok, doc} <- decode_metadata(body), + :ok <- validate_metadata(doc, client_id) do + {:ok, doc} + end + end + + defp validate_https(url) when is_binary(url) do + case URI.parse(url) do + %URI{scheme: "https", host: host} when is_binary(host) and host != "" -> :ok + _ -> {:error, :client_id_not_https} + end + end + + defp validate_https(_), do: {:error, :client_id_not_https} + + defp http_get(url) do + case Application.get_env(:plausible, __MODULE__, [])[:client_metadata_fetcher] do + fun when is_function(fun, 1) -> fun.(url) + module when is_atom(module) and not is_nil(module) -> module.get(url) + _ -> ssrf_get(url) + end + end + + defp ssrf_get(url) do + case Plausible.SSRF.get(url, receive_timeout: @fetch_timeout, decode_body: false) do + {:ok, %Req.Response{status: 200, body: body}} when is_binary(body) -> + if byte_size(body) > @max_metadata_bytes do + {:error, :client_metadata_too_large} + else + {:ok, body} + end + + {:ok, %Req.Response{}} -> + {:error, :client_metadata_unavailable} + + {:error, reason} -> + {:error, reason} + end + end + + defp decode_metadata(body) when is_binary(body) do + case Jason.decode(body) do + {:ok, doc} when is_map(doc) -> {:ok, doc} + _ -> {:error, :invalid_client_metadata} + end + end + + defp decode_metadata(doc) when is_map(doc), do: {:ok, doc} + defp decode_metadata(_), do: {:error, :invalid_client_metadata} + + @doc """ + Checks whether a requested `redirect_uri` is registered in a CIMD document's + `redirect_uris`. + + Non-loopback URIs must match exactly. Loopback URIs (`localhost`, `127.0.0.1`, + `[::1]`) match **ignoring the port**, per RFC 8252 section 7.3 and the MCP + authorization spec: native clients such as Claude Code use an ephemeral + loopback port that isn't known ahead of time, so it can't appear verbatim in + the metadata document. + """ + @spec redirect_uri_registered?(String.t() | nil, [String.t()]) :: boolean() + def redirect_uri_registered?(redirect_uri, registered) when is_binary(redirect_uri) do + redirect_uri in registered or loopback_match?(redirect_uri, registered) + end + + def redirect_uri_registered?(_redirect_uri, _registered), do: false + + defp loopback_match?(redirect_uri, registered) do + uri = URI.parse(redirect_uri) + + if loopback_host?(uri.host) do + Enum.any?(registered, fn candidate -> + registered_uri = URI.parse(candidate) + + loopback_host?(registered_uri.host) and + registered_uri.scheme == uri.scheme and + registered_uri.host == uri.host and + normalize_path(registered_uri.path) == normalize_path(uri.path) + end) + else + false + end + end + + defp loopback_host?(host), do: host in ["localhost", "127.0.0.1", "::1"] + + defp normalize_path(nil), do: "/" + defp normalize_path(""), do: "/" + defp normalize_path(path), do: path + + defp validate_metadata(doc, client_id) do + redirect_uris = doc["redirect_uris"] + + cond do + doc["client_id"] != client_id -> + {:error, :client_id_mismatch} + + not is_list(redirect_uris) or redirect_uris == [] -> + {:error, :missing_redirect_uris} + + not Enum.all?(redirect_uris, &is_binary/1) -> + {:error, :invalid_redirect_uris} + + true -> + :ok + end + end + + ## Cleanup + + @doc """ + Deletes expired authorization codes and fully-expired token rows. A token row + is only removed once its refresh token has also expired (or was never issued + and the access token has expired). + """ + @spec delete_expired() :: %{ + authorization_codes: non_neg_integer(), + access_tokens: non_neg_integer() + } + def delete_expired() do + now = DateTime.utc_now() + + {codes, _} = + Repo.delete_all(from(c in AuthorizationCode, where: c.expires_at < ^now)) + + {tokens, _} = + Repo.delete_all( + from(t in AccessToken, + where: + t.refresh_token_expires_at < ^now or + (is_nil(t.refresh_token_expires_at) and t.access_token_expires_at < ^now) + ) + ) + + %{authorization_codes: codes, access_tokens: tokens} + end + + ## Helpers + + defp check_not_expired(expires_at) do + if DateTime.compare(expires_at, DateTime.utc_now()) == :gt do + :ok + else + {:error, :invalid_grant} + end + end + + defp match(same, same), do: :ok + defp match(_, _), do: {:error, :invalid_grant} +end diff --git a/lib/plausible/oauth/access_token.ex b/lib/plausible/oauth/access_token.ex new file mode 100644 index 000000000000..c9ebe8c74612 --- /dev/null +++ b/lib/plausible/oauth/access_token.ex @@ -0,0 +1,82 @@ +defmodule Plausible.OAuth.AccessToken do + @moduledoc """ + An OAuth 2.1 access token paired with its (optional) refresh token in a single + row. Refreshing rotates the row in place: new access/refresh hashes replace the + previous ones. + + Only hashes and short, non-sensitive prefixes are persisted. + """ + + use Ecto.Schema + import Ecto.Changeset + + @type t() :: %__MODULE__{} + + @required [ + :access_token_hash, + :access_token_prefix, + :client_id, + :access_token_expires_at, + :user_id, + :team_id + ] + @optional [ + :refresh_token_hash, + :refresh_token_prefix, + :refresh_token_expires_at, + :scopes, + :resource, + :last_used_at, + :client_name + ] + + schema "oauth_access_tokens" do + field :access_token_hash, :string + field :access_token_prefix, :string + field :refresh_token_hash, :string + field :refresh_token_prefix, :string + field :client_id, :string + field :client_name, :string + field :scopes, {:array, :string}, default: [] + field :resource, :string + field :access_token_expires_at, :utc_datetime_usec + field :refresh_token_expires_at, :utc_datetime_usec + field :last_used_at, :utc_datetime_usec + + belongs_to :user, Plausible.Auth.User + belongs_to :team, Plausible.Teams.Team + + timestamps() + end + + @spec changeset(map()) :: Ecto.Changeset.t() + def changeset(attrs) do + changeset(%__MODULE__{}, attrs) + end + + @spec changeset(t(), map()) :: Ecto.Changeset.t() + def changeset(struct, attrs) do + struct + |> cast(attrs, @required ++ @optional) + |> validate_required(@required) + |> unique_constraint(:access_token_hash) + |> unique_constraint(:refresh_token_hash) + end + + @spec last_used_humanize(t()) :: String.t() + def last_used_humanize(%__MODULE__{last_used_at: nil}), do: "Not yet" + + def last_used_humanize(%__MODULE__{last_used_at: last_used_at}) do + diff = DateTime.diff(DateTime.utc_now(), last_used_at, :minute) + + cond do + diff < 5 -> "Just recently" + diff < 30 -> "Several minutes ago" + diff < 70 -> "An hour ago" + diff < 24 * 60 -> "Hours ago" + diff < 24 * 60 * 2 -> "Yesterday" + diff < 24 * 60 * 7 -> "Sometime this week" + true -> "Long time ago" + end + end +end diff --git a/lib/plausible/oauth/authorization_code.ex b/lib/plausible/oauth/authorization_code.ex new file mode 100644 index 000000000000..f175dcda4d81 --- /dev/null +++ b/lib/plausible/oauth/authorization_code.ex @@ -0,0 +1,54 @@ +defmodule Plausible.OAuth.AuthorizationCode do + @moduledoc """ + Short-lived, single-use OAuth 2.1 authorization code. + + Only the SHA-256 hash of the code is persisted. The `client_id` is the CIMD + URL (an HTTPS document describing the client). PKCE is mandatory: the + `code_challenge` is captured at authorize-time and verified at token-time. + """ + + use Ecto.Schema + import Ecto.Changeset + + @type t() :: %__MODULE__{} + + @code_challenge_methods ["S256"] + + @required [ + :code_hash, + :client_id, + :redirect_uri, + :code_challenge, + :code_challenge_method, + :expires_at, + :user_id, + :team_id + ] + @optional [:scopes, :resource, :client_name] + + schema "oauth_authorization_codes" do + field :code_hash, :string + field :client_id, :string + field :client_name, :string + field :redirect_uri, :string + field :code_challenge, :string + field :code_challenge_method, :string + field :scopes, {:array, :string}, default: [] + field :resource, :string + field :expires_at, :utc_datetime_usec + + belongs_to :user, Plausible.Auth.User + belongs_to :team, Plausible.Teams.Team + + timestamps(updated_at: false) + end + + @spec changeset(map()) :: Ecto.Changeset.t() + def changeset(attrs) do + %__MODULE__{} + |> cast(attrs, @required ++ @optional) + |> validate_required(@required) + |> validate_inclusion(:code_challenge_method, @code_challenge_methods) + |> unique_constraint(:code_hash) + end +end diff --git a/lib/plausible/oauth/token.ex b/lib/plausible/oauth/token.ex new file mode 100644 index 000000000000..c5545ba30293 --- /dev/null +++ b/lib/plausible/oauth/token.ex @@ -0,0 +1,69 @@ +defmodule Plausible.OAuth.Token do + @moduledoc """ + Helpers for generating and hashing OAuth tokens and authorization codes. + + Raw tokens are prefixed with a plain-text identifier so that secret scanners + (e.g. GitHub secret scanning) can detect accidentally leaked credentials, + mirroring `Plausible.Plugins.API.Token`. Only the hash and a short, + non-sensitive prefix are ever persisted - the raw value is returned once at + creation time and never stored. + """ + + use Plausible + + # Number of leading characters of the raw token stored for support/debugging. + @prefix_length 20 + + @type kind() :: :access | :refresh | :code + + @doc """ + Generates a random, prefixed token of the given kind. + + Returns a map with the `:raw` value (to be handed to the client once), + its `:hash` (to be persisted), and a short `:prefix` (safe to persist and + display). + """ + @spec generate(kind()) :: %{raw: String.t(), hash: String.t(), prefix: String.t()} + def generate(kind) do + random = :crypto.strong_rand_bytes(64) |> Base.url_encode64() |> binary_part(0, 64) + raw = "#{prefix(kind)}-#{random}" + + %{ + raw: raw, + hash: hash(raw), + prefix: binary_part(raw, 0, @prefix_length) + } + end + + @doc """ + Hashes a raw token/code. Reuses the salted SHA-256 scheme used for API keys + so that lookups are constant across the codebase. + """ + @spec hash(String.t()) :: String.t() + def hash(raw), do: Plausible.Auth.ApiKey.do_hash(raw) + + @doc """ + Secret-scanner-friendly plain-text prefix for each token kind. + """ + @spec prefix(kind()) :: String.t() + def prefix(kind) do + suffix = + case kind do + :access -> "at" + :refresh -> "rt" + :code -> "ac" + end + + base = + on_ee do + case Application.get_env(:plausible, :environment) do + "prod" -> "plausible-mcp" + env -> "plausible-mcp-#{env}" + end + else + "plausible-mcp-selfhost" + end + + "#{base}-#{suffix}" + end +end diff --git a/lib/plausible/teams/memberships/leave.ex b/lib/plausible/teams/memberships/leave.ex index 18436fcf6ba8..95c424e84aeb 100644 --- a/lib/plausible/teams/memberships/leave.ex +++ b/lib/plausible/teams/memberships/leave.ex @@ -18,6 +18,11 @@ defmodule Plausible.Teams.Memberships.Leave do Repo.transaction(fn -> delete_membership!(team_membership) + Plausible.OAuth.revoke_grants_for_team_member( + team_membership.user, + team_membership.team + ) + Plausible.Segments.after_user_removed_from_team( team_membership.team, team_membership.user diff --git a/lib/plausible/teams/memberships/remove.ex b/lib/plausible/teams/memberships/remove.ex index 52261f365027..ab2602ee2539 100644 --- a/lib/plausible/teams/memberships/remove.ex +++ b/lib/plausible/teams/memberships/remove.ex @@ -19,6 +19,11 @@ defmodule Plausible.Teams.Memberships.Remove do Repo.transaction(fn -> delete_membership!(team_membership) + Plausible.OAuth.revoke_grants_for_team_member( + team_membership.user, + team_membership.team + ) + Plausible.Segments.after_user_removed_from_team( team_membership.team, team_membership.user diff --git a/lib/plausible_web/controllers/oauth/authorize_controller.ex b/lib/plausible_web/controllers/oauth/authorize_controller.ex new file mode 100644 index 000000000000..7c0e92c500e4 --- /dev/null +++ b/lib/plausible_web/controllers/oauth/authorize_controller.ex @@ -0,0 +1,226 @@ +defmodule PlausibleWeb.OAuth.AuthorizeController do + @moduledoc """ + OAuth 2.1 authorization endpoint (the consent screen). + + Runs under the `:browser` pipeline so `PlausibleWeb.AuthPlug` populates + `current_user`; unauthenticated visitors are bounced to `/login` with a + `return_to` back to the authorize request. + + Client registration is CIMD-only: `client_id` is an HTTPS URL whose metadata + document is fetched and validated here. The issued authorization code binds the + approving user and a single selected team, and threads `resource` (RFC 8707) + and PKCE through to the token exchange. + """ + + use PlausibleWeb, :controller + + alias Plausible.OAuth + + plug :put_view, PlausibleWeb.OAuthView + + @no_team_message "You need to belong to a team before authorizing an application. Please create or join a team and try again." + + def authorize(conn, params) do + if conn.assigns[:current_user] do + case build_context(params) do + {:ok, ctx} -> + render_consent(conn, ctx) + + {:redirect_error, redirect_uri, state, error} -> + redirect_error(conn, redirect_uri, state, error) + + {:render_error, message} -> + render_error_page(conn, message) + end + else + redirect_to_login(conn) + end + end + + def consent(conn, %{"action" => action} = params) do + user = conn.assigns[:current_user] + + if is_nil(user) do + redirect_to_login(conn) + else + case build_context(params) do + {:ok, ctx} -> + handle_decision(conn, user, ctx, action) + + {:redirect_error, redirect_uri, state, error} -> + redirect_error(conn, redirect_uri, state, error) + + {:render_error, message} -> + render_error_page(conn, message) + end + end + end + + def consent(conn, params), do: consent(conn, Map.put(params, "action", "deny")) + + ## Decision handling + + defp handle_decision(conn, _user, ctx, "deny") do + redirect_error(conn, ctx.redirect_uri, ctx.state, "access_denied") + end + + defp handle_decision(conn, user, ctx, "approve") do + case resolve_team(conn, ctx) do + nil -> + # A grant must be bound to exactly one team; refuse to issue a code if the + # approving user has no resolvable team. + render_error_page(conn, @no_team_message) + + team -> + attrs = %{ + client_id: ctx.client_id, + client_name: ctx.client_name, + redirect_uri: ctx.redirect_uri, + code_challenge: ctx.code_challenge, + code_challenge_method: ctx.code_challenge_method, + scopes: ctx.scopes, + resource: ctx.resource + } + + case OAuth.create_authorization_code(user, team, attrs) do + {:ok, code} -> + redirect(conn, + external: redirect_with(ctx.redirect_uri, code: code, state: ctx.state) + ) + + {:error, _} -> + redirect_error(conn, ctx.redirect_uri, ctx.state, "server_error") + end + end + end + + defp handle_decision(conn, _user, ctx, _unknown) do + redirect_error(conn, ctx.redirect_uri, ctx.state, "access_denied") + end + + defp resolve_team(conn, ctx) do + team = + case ctx.team do + nil -> conn.assigns[:current_team] + identifier -> Enum.find(conn.assigns[:teams] || [], &(&1.identifier == identifier)) + end + + team || conn.assigns[:current_team] + end + + ## Validation + + defp build_context(params) do + client_id = params["client_id"] + redirect_uri = params["redirect_uri"] + + with {:ok, metadata} <- fetch_client(client_id), + :ok <- validate_redirect_uri(redirect_uri, metadata) do + # From here the redirect_uri is trusted, so protocol errors are redirected + # back to the client rather than shown as an error page. + state = params["state"] + + cond do + params["response_type"] != "code" -> + {:redirect_error, redirect_uri, state, "unsupported_response_type"} + + blank?(params["code_challenge"]) -> + {:redirect_error, redirect_uri, state, "invalid_request"} + + params["code_challenge_method"] != "S256" -> + {:redirect_error, redirect_uri, state, "invalid_request"} + + match?({:error, :invalid_scope}, OAuth.normalize_scopes(params["scope"])) -> + {:redirect_error, redirect_uri, state, "invalid_scope"} + + true -> + {:ok, scopes} = OAuth.normalize_scopes(params["scope"]) + + {:ok, + %{ + client_id: client_id, + redirect_uri: redirect_uri, + response_type: "code", + code_challenge: params["code_challenge"], + code_challenge_method: "S256", + scopes: scopes, + resource: params["resource"], + state: state, + team: params["team"], + client_name: metadata["client_name"], + client_uri: metadata["client_uri"] + }} + end + end + end + + defp fetch_client(client_id) when is_binary(client_id) and client_id != "" do + case OAuth.fetch_client_metadata(client_id) do + {:ok, metadata} -> {:ok, metadata} + {:error, _} -> {:render_error, "Invalid or unreachable client_id metadata document."} + end + end + + defp fetch_client(_), do: {:render_error, "Missing or invalid client_id."} + + defp validate_redirect_uri(redirect_uri, metadata) do + if OAuth.redirect_uri_registered?(redirect_uri, metadata["redirect_uris"] || []) do + :ok + else + {:render_error, + "The redirect_uri does not match any registered redirect URI for this client."} + end + end + + ## Responses + + defp render_consent(conn, ctx) do + if is_nil(resolve_team(conn, ctx)) do + render_error_page(conn, @no_team_message) + else + render(conn, "authorize.html", + ctx: ctx, + teams: conn.assigns[:teams] || [], + current_team: conn.assigns[:current_team] + ) + end + end + + defp render_error_page(conn, message) do + conn + |> put_status(400) + |> render("error.html", message: message) + end + + defp redirect_error(conn, redirect_uri, state, error) do + redirect(conn, external: redirect_with(redirect_uri, error: error, state: state)) + end + + defp redirect_with(redirect_uri, params) do + query = + params + |> Enum.reject(fn {_k, v} -> is_nil(v) end) + |> URI.encode_query() + + uri = URI.parse(redirect_uri) + existing = uri.query || "" + + merged = + case existing do + "" -> query + _ -> existing <> "&" <> query + end + + URI.to_string(%{uri | query: merged}) + end + + defp redirect_to_login(conn) do + return_to = conn.request_path <> query_suffix(conn.query_string) + redirect(conn, to: Routes.auth_path(conn, :login_form, return_to: return_to)) + end + + defp query_suffix(""), do: "" + defp query_suffix(qs), do: "?" <> qs + + defp blank?(value), do: is_nil(value) or value == "" +end diff --git a/lib/plausible_web/controllers/oauth/metadata_controller.ex b/lib/plausible_web/controllers/oauth/metadata_controller.ex new file mode 100644 index 000000000000..58d16421baf2 --- /dev/null +++ b/lib/plausible_web/controllers/oauth/metadata_controller.ex @@ -0,0 +1,41 @@ +defmodule PlausibleWeb.OAuth.MetadataController do + @moduledoc """ + Serves the discovery documents required for OAuth 2.1 + MCP: + + * RFC 9728 Protected Resource Metadata (PRM) for the `/mcp` resource. + * RFC 8414 Authorization Server metadata, advertising CIMD support and + **no** `registration_endpoint` (client registration is CIMD-only). + """ + + use PlausibleWeb, :controller + + alias Plausible.OAuth + + def protected_resource(conn, _params) do + json(conn, %{ + resource: mcp_url(), + authorization_servers: [issuer()], + scopes_supported: OAuth.supported_scopes(), + bearer_methods_supported: ["header"] + }) + end + + def authorization_server(conn, _params) do + issuer = issuer() + + json(conn, %{ + issuer: issuer, + authorization_endpoint: issuer <> "/login/oauth/authorize", + token_endpoint: issuer <> "/login/oauth/token", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + scopes_supported: OAuth.supported_scopes(), + client_id_metadata_document_supported: true + }) + end + + defp issuer(), do: PlausibleWeb.Endpoint.url() + defp mcp_url(), do: issuer() <> "/mcp" +end diff --git a/lib/plausible_web/controllers/oauth/token_controller.ex b/lib/plausible_web/controllers/oauth/token_controller.ex new file mode 100644 index 000000000000..3c1e6512f06a --- /dev/null +++ b/lib/plausible_web/controllers/oauth/token_controller.ex @@ -0,0 +1,80 @@ +defmodule PlausibleWeb.OAuth.TokenController do + @moduledoc """ + OAuth 2.1 token endpoint. Public client (PKCE, no client authentication). + + Supports the `authorization_code` and `refresh_token` grants. All errors are + returned as `{error, error_description}` JSON with the appropriate status. + """ + + use PlausibleWeb, :controller + + alias Plausible.OAuth + + def token(conn, %{"grant_type" => "authorization_code"} = params) do + with {:ok, code} <- require_param(params, "code"), + {:ok, verifier} <- require_param(params, "code_verifier"), + {:ok, redirect_uri} <- require_param(params, "redirect_uri"), + {:ok, auth_code} <- + OAuth.consume_authorization_code( + code, + verifier, + redirect_uri, + params["resource"] + ), + {:ok, tokens} <- OAuth.issue_tokens(auth_code) do + send_tokens(conn, tokens) + else + {:error, :missing_param, name} -> + send_error(conn, 400, "invalid_request", "Missing required parameter: #{name}") + + {:error, _} -> + send_error(conn, 400, "invalid_grant", "The authorization code is invalid or expired") + end + end + + def token(conn, %{"grant_type" => "refresh_token"} = params) do + with {:ok, refresh_token} <- require_param(params, "refresh_token"), + {:ok, tokens} <- OAuth.refresh_tokens(refresh_token) do + send_tokens(conn, tokens) + else + {:error, :missing_param, name} -> + send_error(conn, 400, "invalid_request", "Missing required parameter: #{name}") + + {:error, _} -> + send_error(conn, 400, "invalid_grant", "The refresh token is invalid or expired") + end + end + + def token(conn, %{"grant_type" => grant_type}) do + send_error( + conn, + 400, + "unsupported_grant_type", + "Unsupported grant_type: #{grant_type}" + ) + end + + def token(conn, _params) do + send_error(conn, 400, "invalid_request", "Missing required parameter: grant_type") + end + + defp send_tokens(conn, tokens) do + conn + |> put_resp_header("cache-control", "no-store") + |> put_resp_header("pragma", "no-cache") + |> json(tokens) + end + + defp require_param(params, name) do + case params[name] do + value when is_binary(value) and value != "" -> {:ok, value} + _ -> {:error, :missing_param, name} + end + end + + defp send_error(conn, status, error, description) do + conn + |> put_status(status) + |> json(%{error: error, error_description: description}) + end +end diff --git a/lib/plausible_web/controllers/settings_controller.ex b/lib/plausible_web/controllers/settings_controller.ex index 749a2bfe2846..00bc81ce479a 100644 --- a/lib/plausible_web/controllers/settings_controller.ex +++ b/lib/plausible_web/controllers/settings_controller.ex @@ -375,9 +375,13 @@ defmodule PlausibleWeb.SettingsController do Auth.User.password_changeset(conn.assigns.current_user) ) + oauth_grants = Plausible.OAuth.list_grants(conn.assigns.current_user) + render(conn, :security, totp_enabled?: Auth.TOTP.enabled?(conn.assigns.current_user), user_sessions: user_sessions, + oauth_grants: oauth_grants, + mcp_enabled?: FunWithFlags.enabled?(:mcp_server), email_changeset: email_changeset, password_changeset: password_changeset, layout: {PlausibleWeb.LayoutView, :settings} @@ -394,6 +398,22 @@ defmodule PlausibleWeb.SettingsController do |> redirect(to: Routes.settings_path(conn, :security) <> "#user-sessions") end + def revoke_oauth_connector(conn, %{"id" => id}) do + current_user = conn.assigns.current_user + + case Plausible.OAuth.revoke_grant(current_user, id) do + :ok -> + conn + |> put_flash(:success, "Connected application revoked successfully") + |> redirect(to: Routes.settings_path(conn, :security) <> "#oauth-connectors") + + {:error, :not_found} -> + conn + |> put_flash(:error, "Connected application not found") + |> redirect(to: Routes.settings_path(conn, :security) <> "#oauth-connectors") + end + end + defp do_update_password(user, params) do changes = Auth.User.password_changeset(user, params) diff --git a/lib/plausible_web/plugs/authorize_oauth_api.ex b/lib/plausible_web/plugs/authorize_oauth_api.ex new file mode 100644 index 000000000000..1db676cd34ae --- /dev/null +++ b/lib/plausible_web/plugs/authorize_oauth_api.ex @@ -0,0 +1,102 @@ +defmodule PlausibleWeb.Plugs.AuthorizeOAuthAPI do + @moduledoc """ + Authenticates a request carrying an OAuth 2.1 Bearer access token — the + resource-server side of the OAuth flow. Mount it on any route that should be + accessible with an issued access token. + + Modeled on `PlausibleWeb.Plugs.AuthorizePublicAPI`: it extracts the Bearer + token, resolves it to an `Plausible.OAuth.AccessToken` (rejecting expired + tokens), enforces the same per-team hourly + burst rate limits, and assigns + `:current_user`, `:current_team` and the token's granted `:oauth_scopes` for + downstream per-scope authorization. + + On any failure it responds `401` with an RFC 9728 `WWW-Authenticate` header + pointing clients at the Protected Resource Metadata document so they can + (re)discover the authorization server. + """ + + use Plausible.Repo + + import Plug.Conn + + alias Plausible.Auth + alias Plausible.OAuth + alias Plausible.RateLimit + + def init(opts), do: opts + + def call(conn, _opts) do + with {:ok, raw_token} <- get_bearer_token(conn), + {:ok, token} <- OAuth.find_access_token(raw_token), + {limit_key, hourly_limit} <- rate_limit_config(token), + :ok <- check_rate_limit(limit_key, hourly_limit), + :ok <- check_burst_limit(limit_key) do + OAuth.mark_used(token) + + conn + |> assign(:current_user, token.user) + |> assign(:current_team, token.team) + |> assign(:oauth_scopes, token.scopes) + else + {:error, :missing_token} -> unauthorized(conn, nil) + {:error, :invalid_token} -> unauthorized(conn, "invalid_token") + {:error, :rate_limit} -> too_many_requests(conn) + end + end + + defp rate_limit_config(%{team: %{} = team}) do + {Auth.ApiKey.limit_key(team), team.hourly_api_request_limit} + end + + defp check_rate_limit(limit_key, hourly_limit) do + case RateLimit.check_rate(limit_key, to_timeout(hour: 1), hourly_limit) do + {:allow, _} -> :ok + {:deny, _} -> {:error, :rate_limit} + end + end + + defp check_burst_limit(limit_key) do + case RateLimit.check_rate( + limit_key, + to_timeout(second: Auth.ApiKey.burst_period_seconds()), + Auth.ApiKey.burst_request_limit() + ) do + {:allow, _} -> :ok + {:deny, _} -> {:error, :rate_limit} + end + end + + defp get_bearer_token(conn) do + case List.first(get_req_header(conn, "authorization")) do + "Bearer " <> token -> {:ok, String.trim(token)} + _ -> {:error, :missing_token} + end + end + + defp unauthorized(conn, error) do + conn + |> put_resp_header("www-authenticate", www_authenticate(error)) + |> put_status(401) + |> Phoenix.Controller.json(%{error: error || "unauthorized"}) + |> halt() + end + + defp too_many_requests(conn) do + conn + |> put_status(429) + |> Phoenix.Controller.json(%{error: "too_many_requests"}) + |> halt() + end + + defp www_authenticate(nil) do + ~s(Bearer resource_metadata="#{prm_url()}") + end + + defp www_authenticate(error) do + ~s(Bearer resource_metadata="#{prm_url()}", error="#{error}") + end + + defp prm_url() do + PlausibleWeb.Endpoint.url() <> "/.well-known/oauth-protected-resource" + end +end diff --git a/lib/plausible_web/plugs/ensure_mcp_enabled.ex b/lib/plausible_web/plugs/ensure_mcp_enabled.ex new file mode 100644 index 000000000000..38fd7343e654 --- /dev/null +++ b/lib/plausible_web/plugs/ensure_mcp_enabled.ex @@ -0,0 +1,26 @@ +defmodule PlausibleWeb.Plugs.EnsureMCPEnabled do + @moduledoc """ + Global kill switch for the MCP server and its OAuth endpoints. + + Checks the `:mcp_server` FunWithFlags flag **globally** (with no actor) so it + can guard unauthenticated endpoints like `.well-known` metadata and the token + endpoint. When the flag is disabled the request 404s, making the whole feature + invisible. `PlausibleWeb.Plugs.FeatureFlagCheckPlug` is per-actor and therefore + only usable on the logged-in consent screen, not here. + """ + + import Plug.Conn + + def init(opts), do: opts + + def call(conn, _opts) do + if FunWithFlags.enabled?(:mcp_server) do + conn + else + conn + |> put_status(404) + |> Phoenix.Controller.json(%{error: "not_found"}) + |> halt() + end + end +end diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex index 6c1439f81f25..d9137c60ed46 100644 --- a/lib/plausible_web/router.ex +++ b/lib/plausible_web/router.ex @@ -109,6 +109,20 @@ defmodule PlausibleWeb.Router do forward "/sent-emails-api", Bamboo.SentEmailApiPlug end + scope "/.well-known", PlausibleWeb do + pipe_through [PlausibleWeb.Plugs.EnsureMCPEnabled] + + # OAuth 2.1 authorization server metadata + get "/oauth-protected-resource", OAuth.MetadataController, :protected_resource + get "/oauth-protected-resource/*any", OAuth.MetadataController, :protected_resource + + get "/oauth-authorization-server", OAuth.MetadataController, :authorization_server + + get "/oauth-authorization-server/*any", + OAuth.MetadataController, + :authorization_server + end + on_ee do live_session :customer_support, on_mount: PlausibleWeb.Live.SuperAdminLiveAuth do @@ -447,6 +461,13 @@ defmodule PlausibleWeb.Router do post "/activate", AuthController, :activate get "/login", AuthController, :login_form post "/login", AuthController, :login + + scope "/login/oauth" do + pipe_through PlausibleWeb.Plugs.EnsureMCPEnabled + get "/authorize", OAuth.AuthorizeController, :authorize + post "/authorize", OAuth.AuthorizeController, :consent + end + get "/password/request-reset", AuthController, :password_reset_request_form post "/password/request-reset", AuthController, :password_reset_request get "/2fa/setup/force-initiate", AuthController, :force_initiate_2fa_setup @@ -465,6 +486,11 @@ defmodule PlausibleWeb.Router do post "/error_report", ErrorReportController, :submit_error_report end + scope "/login/oauth", PlausibleWeb do + pipe_through [:external_api, PlausibleWeb.Plugs.EnsureMCPEnabled] + post "/token", OAuth.TokenController, :token + end + scope "/", PlausibleWeb do pipe_through [:shared_link] @@ -488,6 +514,7 @@ defmodule PlausibleWeb.Router do get "/security", SettingsController, :security delete "/security/user-sessions/:id", SettingsController, :delete_session + delete "/security/oauth-connectors/:id", SettingsController, :revoke_oauth_connector post "/security/email/cancel", SettingsController, :cancel_update_email post "/security/email", SettingsController, :update_email diff --git a/lib/plausible_web/templates/oauth/authorize.html.heex b/lib/plausible_web/templates/oauth/authorize.html.heex new file mode 100644 index 000000000000..9036a2bfeaf9 --- /dev/null +++ b/lib/plausible_web/templates/oauth/authorize.html.heex @@ -0,0 +1,86 @@ +<.auth_container> +
+
+

+ Authorize access +

+

+ {@ctx.client_name || @ctx.client_id} + wants to connect to your Plausible account + + {@ctx.client_uri} + +

+
+ +
+

+ This will allow the application to: +

+ +
+ + <.form + :let={f} + for={@conn} + action="/login/oauth/authorize" + method="post" + class="flex flex-col gap-y-4" + > + + + + + + + + + +
+ + +

+ The connection will only be able to access the data of the selected team. +

+
+ +
+ + +
+ +
+ diff --git a/lib/plausible_web/templates/oauth/error.html.heex b/lib/plausible_web/templates/oauth/error.html.heex new file mode 100644 index 000000000000..aebff56b175f --- /dev/null +++ b/lib/plausible_web/templates/oauth/error.html.heex @@ -0,0 +1,10 @@ +<.auth_container> +
+

+ Authorization error +

+

+ {@message} +

+
+ diff --git a/lib/plausible_web/templates/settings/security.html.heex b/lib/plausible_web/templates/settings/security.html.heex index 9b5b0cfcb689..d13c214605c6 100644 --- a/lib/plausible_web/templates/settings/security.html.heex +++ b/lib/plausible_web/templates/settings/security.html.heex @@ -267,4 +267,49 @@ + + <.tile :if={@mcp_enabled? or @oauth_grants != []} docs="connected-applications"> + <:title> + Connected applications + + <:subtitle> + Applications you've authorized to access your account over MCP. Revoking one + immediately invalidates its access and refresh tokens. + + +

+ You haven't connected any applications yet. +

+ + <.table :if={@oauth_grants != []} rows={@oauth_grants}> + <:thead> + <.th>Application + <.th hide_on_mobile>Team + <.th hide_on_mobile>Scopes + <.th hide_on_mobile>Last used + <.th invisible>Actions + + <:tbody :let={grant}> + <.td truncate max_width="max-w-60"> + {grant.client_name || grant.client_id} + + {grant.client_id} + + + <.td hide_on_mobile>{(grant.team && grant.team.name) || "—"} + <.td hide_on_mobile>{Enum.join(grant.scopes, ", ")} + <.td hide_on_mobile>{Plausible.OAuth.AccessToken.last_used_humanize(grant)} + <.td actions> + <.delete_button + href={Routes.settings_path(@conn, :revoke_oauth_connector, grant.id)} + method="delete" + data-confirm="Are you sure you want to revoke this application's access?" + /> + + + + diff --git a/lib/plausible_web/views/oauth_view.ex b/lib/plausible_web/views/oauth_view.ex new file mode 100644 index 000000000000..463c8c6c4042 --- /dev/null +++ b/lib/plausible_web/views/oauth_view.ex @@ -0,0 +1,18 @@ +defmodule PlausibleWeb.OAuthView do + # Mirrors the `PlausibleWeb, :view` macro but pins the template `path` to + # "oauth" (the default derivation would underscore `OAuth` to "o_auth"). + use Phoenix.View, root: "lib/plausible_web/templates", path: "oauth" + + use Phoenix.Component, global_prefixes: ~w(x-) + + import PlausibleWeb.Components.Generic + + @scope_descriptions %{ + "stats:read:*" => "Read your sites' analytics (Stats API)", + "sites:read:*" => "Read the list and details of your sites" + } + + def scope_description(scope) do + Map.get(@scope_descriptions, scope, scope) + end +end diff --git a/lib/workers/oauth_cleanup.ex b/lib/workers/oauth_cleanup.ex new file mode 100644 index 000000000000..e6ef2a332aab --- /dev/null +++ b/lib/workers/oauth_cleanup.ex @@ -0,0 +1,14 @@ +defmodule Plausible.Workers.OAuthCleanup do + @moduledoc """ + Periodically purges expired OAuth authorization codes and fully-expired + access/refresh token rows. + """ + + use Oban.Worker, queue: :oauth_cleanup + + @impl Oban.Worker + def perform(_job) do + Plausible.OAuth.delete_expired() + :ok + end +end diff --git a/priv/repo/migrations/20260721120000_create_oauth_tables.exs b/priv/repo/migrations/20260721120000_create_oauth_tables.exs new file mode 100644 index 000000000000..3809c9688d5e --- /dev/null +++ b/priv/repo/migrations/20260721120000_create_oauth_tables.exs @@ -0,0 +1,47 @@ +defmodule Plausible.Repo.Migrations.CreateOauthTables do + use Ecto.Migration + + def change do + create table(:oauth_authorization_codes) do + add :code_hash, :string, null: false + add :client_id, :string, null: false + add :client_name, :string + add :redirect_uri, :string, null: false + add :code_challenge, :string, null: false + add :code_challenge_method, :string, null: false + add :scopes, {:array, :string}, null: false, default: [] + add :resource, :string + add :user_id, references(:users, on_delete: :delete_all), null: false + add :team_id, references(:teams, on_delete: :delete_all), null: false + add :expires_at, :utc_datetime_usec, null: false + + timestamps(updated_at: false) + end + + create unique_index(:oauth_authorization_codes, [:code_hash]) + create index(:oauth_authorization_codes, [:expires_at]) + + create table(:oauth_access_tokens) do + add :access_token_hash, :string, null: false + add :access_token_prefix, :string, null: false + add :refresh_token_hash, :string + add :refresh_token_prefix, :string + add :client_id, :string, null: false + add :client_name, :string + add :scopes, {:array, :string}, null: false, default: [] + add :resource, :string + add :user_id, references(:users, on_delete: :delete_all), null: false + add :team_id, references(:teams, on_delete: :delete_all), null: false + add :access_token_expires_at, :utc_datetime_usec, null: false + add :refresh_token_expires_at, :utc_datetime_usec + add :last_used_at, :utc_datetime_usec + + timestamps() + end + + create unique_index(:oauth_access_tokens, [:access_token_hash]) + create unique_index(:oauth_access_tokens, [:refresh_token_hash]) + create index(:oauth_access_tokens, [:access_token_expires_at]) + create index(:oauth_access_tokens, [:user_id]) + end +end diff --git a/test/plausible/oauth_test.exs b/test/plausible/oauth_test.exs new file mode 100644 index 000000000000..6c17ca404cb9 --- /dev/null +++ b/test/plausible/oauth_test.exs @@ -0,0 +1,515 @@ +defmodule Plausible.OAuthTest do + use Plausible.DataCase, async: true + use Plausible.Test.Support.DNS + + alias Plausible.OAuth + alias Plausible.OAuth.{AccessToken, AuthorizationCode, Token} + + @redirect_uri "https://client.example/callback" + @client_id "https://client.example/oauth-metadata" + @resource "https://plausible.example/mcp" + + defp verifier_and_challenge do + verifier = :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false) + challenge = :crypto.hash(:sha256, verifier) |> Base.url_encode64(padding: false) + {verifier, challenge} + end + + defp code_attrs(challenge, overrides \\ %{}) do + Map.merge( + %{ + client_id: @client_id, + redirect_uri: @redirect_uri, + code_challenge: challenge, + code_challenge_method: "S256", + scopes: ["stats:read:*", "sites:read:*"], + resource: @resource + }, + overrides + ) + end + + describe "verify_pkce/3" do + test "accepts a valid S256 verifier" do + {verifier, challenge} = verifier_and_challenge() + assert OAuth.verify_pkce(challenge, verifier, "S256") == :ok + end + + test "rejects an incorrect verifier" do + {_verifier, challenge} = verifier_and_challenge() + assert OAuth.verify_pkce(challenge, "not-the-verifier", "S256") == {:error, :invalid_grant} + end + + test "rejects the plain method even when values match" do + {verifier, _challenge} = verifier_and_challenge() + assert OAuth.verify_pkce(verifier, verifier, "plain") == {:error, :invalid_grant} + end + + test "rejects missing verifier" do + {_verifier, challenge} = verifier_and_challenge() + assert OAuth.verify_pkce(challenge, nil, "S256") == {:error, :invalid_grant} + end + end + + describe "normalize_scopes/1" do + @all ["stats:read:*", "sites:read:*"] + + test "defaults to all supported scopes when absent, empty or blank" do + assert OAuth.normalize_scopes(nil) == {:ok, @all} + assert OAuth.normalize_scopes("") == {:ok, @all} + assert OAuth.normalize_scopes(" ") == {:ok, @all} + end + + test "accepts a subset of supported scopes in canonical order" do + assert OAuth.normalize_scopes("sites:read:*") == {:ok, ["sites:read:*"]} + assert OAuth.normalize_scopes("sites:read:* stats:read:*") == {:ok, @all} + end + + test "rejects the whole request if any scope is unsupported" do + assert OAuth.normalize_scopes("stats:write:*") == {:error, :invalid_scope} + assert OAuth.normalize_scopes("bogus other") == {:error, :invalid_scope} + assert OAuth.normalize_scopes("stats:read:* bogus:scope") == {:error, :invalid_scope} + end + end + + describe "authorization codes" do + setup do + user = new_user() + {:ok, team} = Plausible.Teams.get_or_create(user) + {:ok, user: user, team: team} + end + + test "create + consume round-trips and is single-use", %{user: user, team: team} do + {verifier, challenge} = verifier_and_challenge() + {:ok, raw} = OAuth.create_authorization_code(user, team, code_attrs(challenge)) + + assert {:ok, %AuthorizationCode{} = code} = + OAuth.consume_authorization_code(raw, verifier, @redirect_uri, @resource) + + assert code.user_id == user.id + assert code.team_id == team.id + assert code.scopes == ["stats:read:*", "sites:read:*"] + + # Second consumption fails - the code was deleted. + assert {:error, :invalid_grant} = + OAuth.consume_authorization_code(raw, verifier, @redirect_uri, @resource) + end + + test "refuses to create a code without a team", %{user: user} do + {_verifier, challenge} = verifier_and_challenge() + + assert {:error, changeset} = + OAuth.create_authorization_code(user, nil, code_attrs(challenge)) + + refute changeset.valid? + assert Keyword.has_key?(changeset.errors, :team_id) + end + + test "rejects a bad PKCE verifier", %{user: user, team: team} do + {_verifier, challenge} = verifier_and_challenge() + {:ok, raw} = OAuth.create_authorization_code(user, team, code_attrs(challenge)) + + assert {:error, :invalid_grant} = + OAuth.consume_authorization_code(raw, "wrong", @redirect_uri, @resource) + end + + test "rejects redirect_uri mismatch", %{user: user, team: team} do + {verifier, challenge} = verifier_and_challenge() + {:ok, raw} = OAuth.create_authorization_code(user, team, code_attrs(challenge)) + + assert {:error, :invalid_grant} = + OAuth.consume_authorization_code( + raw, + verifier, + "https://evil.example/cb", + @resource + ) + end + + test "rejects resource mismatch", %{user: user, team: team} do + {verifier, challenge} = verifier_and_challenge() + {:ok, raw} = OAuth.create_authorization_code(user, team, code_attrs(challenge)) + + assert {:error, :invalid_grant} = + OAuth.consume_authorization_code(raw, verifier, @redirect_uri, "https://evil/mcp") + end + + test "client_name propagates from the authorization code to the grant", %{ + user: user, + team: team + } do + {verifier, challenge} = verifier_and_challenge() + attrs = code_attrs(challenge, %{client_name: "Claude Code"}) + + {:ok, raw} = OAuth.create_authorization_code(user, team, attrs) + {:ok, code} = OAuth.consume_authorization_code(raw, verifier, @redirect_uri, @resource) + assert code.client_name == "Claude Code" + + {:ok, _tokens} = OAuth.issue_tokens(code) + assert [grant] = OAuth.list_grants(user) + assert grant.client_name == "Claude Code" + end + + test "rejects an expired code", %{user: user, team: team} do + {verifier, challenge} = verifier_and_challenge() + code = Token.generate(:code) + + Repo.insert!( + AuthorizationCode.changeset(%{ + code_hash: code.hash, + client_id: @client_id, + redirect_uri: @redirect_uri, + code_challenge: challenge, + code_challenge_method: "S256", + scopes: ["stats:read:*"], + resource: @resource, + user_id: user.id, + team_id: team.id, + expires_at: DateTime.add(DateTime.utc_now(), -60, :second) + }) + ) + + assert {:error, :invalid_grant} = + OAuth.consume_authorization_code(code.raw, verifier, @redirect_uri, @resource) + end + end + + describe "tokens" do + setup do + user = new_user() + {:ok, team} = Plausible.Teams.get_or_create(user) + {verifier, challenge} = verifier_and_challenge() + {:ok, raw} = OAuth.create_authorization_code(user, team, code_attrs(challenge)) + {:ok, code} = OAuth.consume_authorization_code(raw, verifier, @redirect_uri, @resource) + {:ok, tokens} = OAuth.issue_tokens(code) + {:ok, user: user, team: team, tokens: tokens} + end + + test "issue_tokens returns a standard token response", %{tokens: tokens} do + assert tokens.token_type == "Bearer" + assert tokens.expires_in == OAuth.access_token_ttl() + assert is_binary(tokens.access_token) + assert is_binary(tokens.refresh_token) + assert tokens.scope == "stats:read:* sites:read:*" + end + + test "find_access_token resolves a valid token with preloads", %{ + tokens: tokens, + user: user, + team: team + } do + assert {:ok, token} = OAuth.find_access_token(tokens.access_token) + assert token.user.id == user.id + assert token.team.id == team.id + end + + test "refresh rotates the pair and invalidates the old tokens", %{tokens: tokens} do + assert {:ok, new_tokens} = OAuth.refresh_tokens(tokens.refresh_token) + refute new_tokens.access_token == tokens.access_token + refute new_tokens.refresh_token == tokens.refresh_token + + # Old refresh token can't be reused. + assert {:error, :invalid_grant} = OAuth.refresh_tokens(tokens.refresh_token) + # Old access token no longer resolves. + assert {:error, :invalid_token} = OAuth.find_access_token(tokens.access_token) + # New access token works. + assert {:ok, _} = OAuth.find_access_token(new_tokens.access_token) + end + + test "find_access_token rejects expired tokens", %{user: user, team: team} do + access = Token.generate(:access) + + Repo.insert!( + AccessToken.changeset(%{ + access_token_hash: access.hash, + access_token_prefix: access.prefix, + client_id: @client_id, + scopes: ["stats:read:*"], + user_id: user.id, + team_id: team.id, + access_token_expires_at: DateTime.add(DateTime.utc_now(), -60, :second) + }) + ) + + assert {:error, :invalid_token} = OAuth.find_access_token(access.raw) + end + end + + describe "grants management" do + setup do + user = new_user() + {:ok, team} = Plausible.Teams.get_or_create(user) + {:ok, user: user, team: team} + end + + defp insert_token(user, team, opts) do + access = Token.generate(:access) + now = DateTime.utc_now() + + Repo.insert!( + AccessToken.changeset(%{ + access_token_hash: access.hash, + access_token_prefix: access.prefix, + refresh_token_hash: Token.generate(:refresh).hash, + refresh_token_prefix: "plausible-mcp-rt-x", + client_id: Keyword.get(opts, :client_id, @client_id), + scopes: ["stats:read:*"], + user_id: user.id, + team_id: team.id, + access_token_expires_at: + Keyword.get(opts, :access_expires_at, DateTime.add(now, 3600, :second)), + refresh_token_expires_at: + Keyword.get(opts, :refresh_expires_at, DateTime.add(now, 86_400, :second)) + }) + ) + end + + test "list_grants returns usable grants, newest first, excluding fully-expired", %{ + user: user, + team: team + } do + now = DateTime.utc_now() + + _expired = + insert_token(user, team, + access_expires_at: DateTime.add(now, -120, :second), + refresh_expires_at: DateTime.add(now, -60, :second) + ) + + active = insert_token(user, team, client_id: "https://client.example/active") + + # A grant whose access token expired but refresh is still valid is still usable. + refreshable = + insert_token(user, team, + client_id: "https://client.example/refreshable", + access_expires_at: DateTime.add(now, -60, :second) + ) + + grants = OAuth.list_grants(user) + ids = Enum.map(grants, & &1.id) + + assert active.id in ids + assert refreshable.id in ids + assert length(grants) == 2 + assert Enum.all?(grants, &Ecto.assoc_loaded?(&1.team)) + end + + test "list_grants is scoped to the user", %{user: user, team: team} do + other = new_user() + {:ok, other_team} = Plausible.Teams.get_or_create(other) + insert_token(other, other_team, []) + + grant = insert_token(user, team, []) + + assert Enum.map(OAuth.list_grants(user), & &1.id) == [grant.id] + end + + test "revoke_grant deletes the row and is user-scoped", %{user: user, team: team} do + grant = insert_token(user, team, []) + + assert :ok = OAuth.revoke_grant(user, grant.id) + assert OAuth.list_grants(user) == [] + # Already gone. + assert {:error, :not_found} = OAuth.revoke_grant(user, grant.id) + end + + test "revoke_grant won't delete another user's grant", %{user: user, team: team} do + other = new_user() + grant = insert_token(user, team, []) + + assert {:error, :not_found} = OAuth.revoke_grant(other, grant.id) + assert [_] = OAuth.list_grants(user) + end + + test "revoke_grants_for_team_member deletes only that user+team's grants", %{ + user: user, + team: team + } do + other_user = new_user() + {:ok, other_team} = Plausible.Teams.get_or_create(other_user) + + kept_other_user = insert_token(other_user, other_team, []) + # Same user, but a different team - must be kept. + kept_other_team = insert_token(user, other_team, []) + _revoked_1 = insert_token(user, team, []) + _revoked_2 = insert_token(user, team, []) + + assert 2 = OAuth.revoke_grants_for_team_member(user, team) + + remaining = Repo.all(AccessToken) |> Enum.map(& &1.id) |> Enum.sort() + assert remaining == Enum.sort([kept_other_user.id, kept_other_team.id]) + end + + test "grants are cascade-deleted at the DB level when the user is deleted", %{ + user: user, + team: team + } do + insert_token(user, team, []) + assert Repo.aggregate(AccessToken, :count) == 1 + + # Account deletion doesn't go through the team-membership removal path, but + # the `on_delete: :delete_all` FK on user_id guarantees the grant is purged. + assert {:ok, :deleted} = Plausible.Auth.delete_user(user) + assert Repo.aggregate(AccessToken, :count) == 0 + end + + test "grants are cascade-deleted at the DB level when the team is deleted", %{team: team} do + # Use a separate owner so deleting the team doesn't also delete our user. + user = new_user() + insert_token(user, team, []) + assert Repo.aggregate(AccessToken, :count) == 1 + + Repo.delete!(team) + assert Repo.aggregate(AccessToken, :count) == 0 + end + + test "mark_used stamps last_used_at and throttles subsequent writes", %{ + user: user, + team: team + } do + token = insert_token(user, team, []) + assert is_nil(token.last_used_at) + + assert :ok = OAuth.mark_used(token) + t1 = Repo.reload!(token).last_used_at + assert t1 + + # Second call within the throttle window doesn't move the timestamp. + assert :ok = OAuth.mark_used(token) + t2 = Repo.reload!(token).last_used_at + assert t2 == t1 + end + end + + describe "fetch_client_metadata/1" do + test "rejects non-HTTPS client_id" do + assert OAuth.fetch_client_metadata("http://client.example/meta") == + {:error, :client_id_not_https} + end + + test "uses the SSRF-safe client and rejects restricted addresses" do + # No :client_metadata_fetcher override -> the real Plausible.SSRF client is + # used. Resolve the client_id host to a private address; SSRF must refuse to + # connect before any request is made. + stub_dns(%{"client.example" => {[{10, 0, 0, 1}], []}}) + + assert {:error, :restricted_address} = OAuth.fetch_client_metadata(@client_id) + end + + test "rejects a non-resolvable client_id host via the SSRF client" do + stub_dns(%{"client.example" => {[], []}}) + + assert {:error, :dns_resolution_failed} = OAuth.fetch_client_metadata(@client_id) + end + + test "validates a self-referential document with a configured fetcher" do + with_fetcher(fn url -> + {:ok, + Jason.encode!(%{ + "client_id" => url, + "redirect_uris" => [@redirect_uri], + "client_name" => "Test Client" + })} + end) + + assert {:ok, doc} = OAuth.fetch_client_metadata(@client_id) + assert doc["client_id"] == @client_id + assert doc["redirect_uris"] == [@redirect_uri] + end + + test "rejects a document whose client_id does not match the URL" do + with_fetcher(fn _url -> + {:ok, + Jason.encode!(%{"client_id" => "https://other", "redirect_uris" => [@redirect_uri]})} + end) + + assert OAuth.fetch_client_metadata(@client_id) == {:error, :client_id_mismatch} + end + + test "rejects a document without redirect_uris" do + with_fetcher(fn url -> + {:ok, Jason.encode!(%{"client_id" => url})} + end) + + assert OAuth.fetch_client_metadata(@client_id) == {:error, :missing_redirect_uris} + end + end + + describe "redirect_uri_registered?/2" do + test "requires an exact match for non-loopback URIs" do + registered = ["https://client.example/cb"] + assert OAuth.redirect_uri_registered?("https://client.example/cb", registered) + refute OAuth.redirect_uri_registered?("https://client.example/other", registered) + refute OAuth.redirect_uri_registered?("https://evil.example/cb", registered) + end + + test "matches loopback URIs ignoring the port (RFC 8252)" do + # Claude Code declares these and connects on an ephemeral port. + registered = ["http://localhost/callback", "http://127.0.0.1/callback"] + + assert OAuth.redirect_uri_registered?("http://localhost:3118/callback", registered) + assert OAuth.redirect_uri_registered?("http://127.0.0.1:52001/callback", registered) + end + + test "loopback match still enforces scheme, host and path" do + registered = ["http://localhost/callback"] + + refute OAuth.redirect_uri_registered?("https://localhost:3000/callback", registered) + refute OAuth.redirect_uri_registered?("http://localhost:3000/evil", registered) + # Host must match: a public host is never treated as loopback. + refute OAuth.redirect_uri_registered?("http://evil.example:3000/callback", registered) + end + + test "handles nil" do + refute OAuth.redirect_uri_registered?(nil, ["http://localhost/callback"]) + end + end + + describe "delete_expired/0" do + test "purges expired codes and fully-expired tokens" do + user = new_user() + {:ok, team} = Plausible.Teams.get_or_create(user) + now = DateTime.utc_now() + + expired_code = Token.generate(:code) + + Repo.insert!( + AuthorizationCode.changeset(%{ + code_hash: expired_code.hash, + client_id: @client_id, + redirect_uri: @redirect_uri, + code_challenge: "x", + code_challenge_method: "S256", + user_id: user.id, + team_id: team.id, + expires_at: DateTime.add(now, -60, :second) + }) + ) + + expired_token = Token.generate(:access) + + Repo.insert!( + AccessToken.changeset(%{ + access_token_hash: expired_token.hash, + access_token_prefix: expired_token.prefix, + client_id: @client_id, + user_id: user.id, + team_id: team.id, + access_token_expires_at: DateTime.add(now, -120, :second), + refresh_token_expires_at: DateTime.add(now, -60, :second) + }) + ) + + assert %{authorization_codes: codes, access_tokens: tokens} = OAuth.delete_expired() + assert codes >= 1 + assert tokens >= 1 + assert Repo.aggregate(AuthorizationCode, :count) == 0 + assert Repo.aggregate(AccessToken, :count) == 0 + end + end + + defp with_fetcher(fun) do + Application.put_env(:plausible, Plausible.OAuth, client_metadata_fetcher: fun) + on_exit(fn -> Application.delete_env(:plausible, Plausible.OAuth) end) + end +end diff --git a/test/plausible_web/controllers/oauth/metadata_controller_test.exs b/test/plausible_web/controllers/oauth/metadata_controller_test.exs new file mode 100644 index 000000000000..74b1edfbed4c --- /dev/null +++ b/test/plausible_web/controllers/oauth/metadata_controller_test.exs @@ -0,0 +1,56 @@ +defmodule PlausibleWeb.OAuth.MetadataControllerTest do + use PlausibleWeb.ConnCase, async: false + + describe "with :mcp_server enabled" do + setup do + # Enable the global flag via the Ecto store (sandbox-scoped, rolled back at + # the end of the test). The cache is flushed on exit so the flag reads back + # as disabled for subsequent tests without a post-sandbox DB write. + FunWithFlags.enable(:mcp_server) + on_exit(fn -> FunWithFlags.Store.Cache.flush() end) + :ok + end + + test "GET /.well-known/oauth-authorization-server advertises CIMD and no DCR", %{conn: conn} do + resp = conn |> get("/.well-known/oauth-authorization-server") |> json_response(200) + + assert resp["issuer"] == PlausibleWeb.Endpoint.url() + assert resp["authorization_endpoint"] =~ "/login/oauth/authorize" + assert resp["token_endpoint"] =~ "/login/oauth/token" + assert resp["response_types_supported"] == ["code"] + assert resp["grant_types_supported"] == ["authorization_code", "refresh_token"] + assert resp["code_challenge_methods_supported"] == ["S256"] + assert resp["token_endpoint_auth_methods_supported"] == ["none"] + assert resp["client_id_metadata_document_supported"] == true + assert "stats:read:*" in resp["scopes_supported"] + # CIMD-only: no dynamic client registration endpoint. + refute Map.has_key?(resp, "registration_endpoint") + end + + test "well-known AS metadata is also served under the /mcp suffix", %{conn: conn} do + resp = conn |> get("/.well-known/oauth-authorization-server/mcp") |> json_response(200) + assert resp["client_id_metadata_document_supported"] == true + end + + test "GET /.well-known/oauth-protected-resource returns PRM", %{conn: conn} do + resp = conn |> get("/.well-known/oauth-protected-resource") |> json_response(200) + + assert resp["resource"] == PlausibleWeb.Endpoint.url() <> "/mcp" + assert resp["authorization_servers"] == [PlausibleWeb.Endpoint.url()] + assert resp["bearer_methods_supported"] == ["header"] + assert "stats:read:*" in resp["scopes_supported"] + end + + test "PRM is also served under the /mcp suffix", %{conn: conn} do + resp = conn |> get("/.well-known/oauth-protected-resource/mcp") |> json_response(200) + assert resp["resource"] == PlausibleWeb.Endpoint.url() <> "/mcp" + end + end + + describe "with :mcp_server disabled" do + test "well-known endpoints 404", %{conn: conn} do + assert conn |> get("/.well-known/oauth-authorization-server") |> json_response(404) + assert conn |> get("/.well-known/oauth-protected-resource") |> json_response(404) + end + end +end diff --git a/test/plausible_web/controllers/oauth/oauth_flow_test.exs b/test/plausible_web/controllers/oauth/oauth_flow_test.exs new file mode 100644 index 000000000000..2a27daece0aa --- /dev/null +++ b/test/plausible_web/controllers/oauth/oauth_flow_test.exs @@ -0,0 +1,389 @@ +defmodule PlausibleWeb.OAuth.FlowTest do + use PlausibleWeb.ConnCase, async: false + + @redirect_uri "https://client.example/callback" + @client_id "https://client.example/oauth-metadata" + @resource "https://plausible.example/mcp" + + setup %{conn: conn} do + FunWithFlags.enable(:mcp_server) + on_exit(fn -> FunWithFlags.Store.Cache.flush() end) + + put_fetcher(fn url -> + {:ok, + Jason.encode!(%{ + "client_id" => url, + "redirect_uris" => [@redirect_uri], + "client_name" => "Test Client" + })} + end) + + user = new_user() + {:ok, team} = Plausible.Teams.get_or_create(user) + {:ok, conn: conn} = log_in(%{user: user, conn: conn}) + + {:ok, conn: conn, user: user, team: team} + end + + defp put_fetcher(fun) do + Application.put_env(:plausible, Plausible.OAuth, client_metadata_fetcher: fun) + on_exit(fn -> Application.delete_env(:plausible, Plausible.OAuth) end) + end + + defp pkce do + verifier = :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false) + challenge = :crypto.hash(:sha256, verifier) |> Base.url_encode64(padding: false) + {verifier, challenge} + end + + defp authorize_params(challenge, overrides \\ %{}) do + Map.merge( + %{ + "client_id" => @client_id, + "redirect_uri" => @redirect_uri, + "response_type" => "code", + "code_challenge" => challenge, + "code_challenge_method" => "S256", + "scope" => "stats:read:* sites:read:*", + "state" => "xyz-state", + "resource" => @resource + }, + overrides + ) + end + + defp token_conn do + build_conn() |> put_req_header("accept", "application/json") + end + + defp code_from_redirect(conn) do + location = redirected_to(conn, 302) + assert String.starts_with?(location, @redirect_uri) + %URI{query: query} = URI.parse(location) + URI.decode_query(query) + end + + # Models a client following an absolute URL it read out of a discovery + # document: for an in-process request we only need the path, which is exactly + # the coupling a real client has — it uses whatever the server advertises. + defp path_of(url), do: URI.parse(url).path + + # RFC 8414 §3: the authorization server metadata URL is the well-known segment + # inserted between the issuer's host and path. Our issuer is path-less, so it + # lands at the origin root. + defp as_metadata_url(issuer) do + uri = URI.parse(issuer) + + %{uri | path: "/.well-known/oauth-authorization-server" <> (uri.path || "")} + |> URI.to_string() + end + + describe "GET /login/oauth/authorize" do + test "renders the consent screen with client and scope info", %{conn: conn} do + {_verifier, challenge} = pkce() + conn = get(conn, "/login/oauth/authorize?" <> URI.encode_query(authorize_params(challenge))) + + html = html_response(conn, 200) + assert html =~ "Authorize access" + assert html =~ "Test Client" + assert html =~ "Stats API" + end + + test "404s when the :mcp_server flag is disabled", %{conn: conn} do + FunWithFlags.disable(:mcp_server) + {_verifier, challenge} = pkce() + conn = get(conn, "/login/oauth/authorize?" <> URI.encode_query(authorize_params(challenge))) + assert conn.status == 404 + end + + test "redirects to login when unauthenticated" do + {_verifier, challenge} = pkce() + + conn = + build_conn() + |> get("/login/oauth/authorize?" <> URI.encode_query(authorize_params(challenge))) + + assert redirected_to(conn) =~ "/login" + end + + test "renders an error page for an invalid client_id", %{conn: conn} do + put_fetcher(fn _url -> {:error, :boom} end) + {_verifier, challenge} = pkce() + + conn = get(conn, "/login/oauth/authorize?" <> URI.encode_query(authorize_params(challenge))) + assert html_response(conn, 400) =~ "Authorization error" + end + + test "redirects back with error for unsupported response_type", %{conn: conn} do + {_verifier, challenge} = pkce() + params = authorize_params(challenge, %{"response_type" => "token"}) + + conn = get(conn, "/login/oauth/authorize?" <> URI.encode_query(params)) + query = code_from_redirect(conn) + assert query["error"] == "unsupported_response_type" + assert query["state"] == "xyz-state" + end + + test "redirects back with invalid_request when code_challenge missing", %{conn: conn} do + params = authorize_params("", %{"code_challenge" => ""}) + conn = get(conn, "/login/oauth/authorize?" <> URI.encode_query(params)) + assert code_from_redirect(conn)["error"] == "invalid_request" + end + + test "redirects back with invalid_scope when no supported scope requested", %{conn: conn} do + {_verifier, challenge} = pkce() + params = authorize_params(challenge, %{"scope" => "stats:write:*"}) + + conn = get(conn, "/login/oauth/authorize?" <> URI.encode_query(params)) + query = code_from_redirect(conn) + assert query["error"] == "invalid_scope" + assert query["state"] == "xyz-state" + end + + test "defaults to all supported scopes when scope is absent", %{conn: conn} do + {_verifier, challenge} = pkce() + + params = + challenge + |> authorize_params() + |> Map.delete("scope") + + conn = get(conn, "/login/oauth/authorize?" <> URI.encode_query(params)) + assert html_response(conn, 200) =~ "Authorize access" + end + end + + describe "when the user has no team" do + setup %{conn: conn} do + # Re-log-in as a freshly-created user with no team memberships at all. + {:ok, conn: conn} = log_in(%{user: new_user(), conn: conn}) + {:ok, conn: conn} + end + + test "GET consent shows an error instead of the form", %{conn: conn} do + {_verifier, challenge} = pkce() + conn = get(conn, "/login/oauth/authorize?" <> URI.encode_query(authorize_params(challenge))) + + assert html_response(conn, 400) =~ "belong to a team" + end + + test "POST approve is refused and issues no code", %{conn: conn} do + {_verifier, challenge} = pkce() + + conn = + post( + conn, + "/login/oauth/authorize", + authorize_params(challenge, %{"action" => "approve"}) + ) + + assert html_response(conn, 400) =~ "belong to a team" + assert Plausible.Repo.aggregate(Plausible.OAuth.AuthorizationCode, :count) == 0 + end + end + + describe "full authorization_code + refresh flow" do + test "approve -> code -> token -> refresh", %{conn: conn, user: user} do + {verifier, challenge} = pkce() + + # Approve consent + conn = + post( + conn, + "/login/oauth/authorize", + authorize_params(challenge, %{"action" => "approve"}) + ) + + query = code_from_redirect(conn) + assert query["state"] == "xyz-state" + code = query["code"] + assert is_binary(code) + + # Exchange the code + resp = + token_conn() + |> post("/login/oauth/token", %{ + "grant_type" => "authorization_code", + "code" => code, + "code_verifier" => verifier, + "redirect_uri" => @redirect_uri, + "resource" => @resource + }) + |> json_response(200) + + assert resp["token_type"] == "Bearer" + assert is_binary(resp["access_token"]) + assert is_binary(resp["refresh_token"]) + assert resp["expires_in"] == Plausible.OAuth.access_token_ttl() + + # The client name from the CIMD document is captured on the grant. + assert [grant] = Plausible.OAuth.list_grants(user) + assert grant.client_name == "Test Client" + + # Refresh rotates the tokens + refreshed = + token_conn() + |> post("/login/oauth/token", %{ + "grant_type" => "refresh_token", + "refresh_token" => resp["refresh_token"] + }) + |> json_response(200) + + assert is_binary(refreshed["access_token"]) + refute refreshed["access_token"] == resp["access_token"] + end + + test "the code is single-use", %{conn: conn} do + {verifier, challenge} = pkce() + + conn = + post( + conn, + "/login/oauth/authorize", + authorize_params(challenge, %{"action" => "approve"}) + ) + + code = code_from_redirect(conn)["code"] + + exchange = fn -> + token_conn() + |> post("/login/oauth/token", %{ + "grant_type" => "authorization_code", + "code" => code, + "code_verifier" => verifier, + "redirect_uri" => @redirect_uri, + "resource" => @resource + }) + end + + assert exchange.() |> json_response(200) + assert exchange.() |> json_response(400) |> Map.get("error") == "invalid_grant" + end + + test "deny redirects with access_denied", %{conn: conn} do + {_verifier, challenge} = pkce() + + conn = + post(conn, "/login/oauth/authorize", authorize_params(challenge, %{"action" => "deny"})) + + assert code_from_redirect(conn)["error"] == "access_denied" + end + end + + describe "discovery-driven flow (client follows advertised metadata)" do + # This test hardcodes NO OAuth endpoint path. Every path it hits is read out + # of the discovery documents at request time: PRM -> authorization server + # metadata -> {authorization,token}_endpoint. Rename the routes (and the + # metadata that advertises them) and this test follows along automatically — + # which is the point: those endpoint locations are discovered, not fixed by + # the protocol. Contrast the well-known paths, which are fixed by RFC 8414 / + # RFC 9728 and therefore *are* spelled out literally here. + test "approve -> code -> token -> refresh through discovered endpoints", %{ + conn: conn, + user: user + } do + # 1. Protected Resource Metadata names the authorization server(s) and the + # resource. A client discovers this unauthenticated. + prm = + build_conn() + |> get("/.well-known/oauth-protected-resource") + |> json_response(200) + + assert [as_issuer] = prm["authorization_servers"] + resource = prm["resource"] + + # 2. Fetch the authorization server metadata from the discovered issuer and + # read the endpoints it advertises. + as_meta = + build_conn() + |> get(path_of(as_metadata_url(as_issuer))) + |> json_response(200) + + authorize_path = path_of(as_meta["authorization_endpoint"]) + token_path = path_of(as_meta["token_endpoint"]) + + # These came from metadata, not from a literal in this test. + assert String.starts_with?(authorize_path, "/") + assert String.starts_with?(token_path, "/") + + # 3. Drive the whole grant through the discovered paths and resource only. + {verifier, challenge} = pkce() + + approve_params = + challenge + |> authorize_params(%{"action" => "approve"}) + |> Map.put("resource", resource) + + code = + conn + |> post(authorize_path, approve_params) + |> code_from_redirect() + |> Map.get("code") + + assert is_binary(code) + + resp = + token_conn() + |> post(token_path, %{ + "grant_type" => "authorization_code", + "code" => code, + "code_verifier" => verifier, + "redirect_uri" => @redirect_uri, + "resource" => resource + }) + |> json_response(200) + + assert resp["token_type"] == "Bearer" + assert is_binary(resp["access_token"]) + assert is_binary(resp["refresh_token"]) + assert [grant] = Plausible.OAuth.list_grants(user) + assert grant.client_name == "Test Client" + + # The discovered token endpoint serves refresh too. + refreshed = + token_conn() + |> post(token_path, %{ + "grant_type" => "refresh_token", + "refresh_token" => resp["refresh_token"] + }) + |> json_response(200) + + assert is_binary(refreshed["access_token"]) + refute refreshed["access_token"] == resp["access_token"] + end + end + + describe "POST /login/oauth/token errors" do + test "invalid code returns invalid_grant" do + resp = + token_conn() + |> post("/login/oauth/token", %{ + "grant_type" => "authorization_code", + "code" => "nope", + "code_verifier" => "v", + "redirect_uri" => @redirect_uri + }) + |> json_response(400) + + assert resp["error"] == "invalid_grant" + end + + test "unsupported grant_type" do + resp = + token_conn() + |> post("/login/oauth/token", %{"grant_type" => "client_credentials"}) + |> json_response(400) + + assert resp["error"] == "unsupported_grant_type" + end + + test "missing grant_type" do + resp = + token_conn() + |> post("/login/oauth/token", %{}) + |> json_response(400) + + assert resp["error"] == "invalid_request" + end + end +end diff --git a/test/plausible_web/controllers/settings_controller_oauth_test.exs b/test/plausible_web/controllers/settings_controller_oauth_test.exs new file mode 100644 index 000000000000..39752de505d2 --- /dev/null +++ b/test/plausible_web/controllers/settings_controller_oauth_test.exs @@ -0,0 +1,71 @@ +defmodule PlausibleWeb.SettingsControllerOAuthTest do + use PlausibleWeb.ConnCase, async: true + + alias Plausible.Repo + alias Plausible.OAuth.{AccessToken, Token} + + setup [:create_user, :log_in] + + defp insert_grant(user, opts \\ []) do + {:ok, team} = Plausible.Teams.get_or_create(user) + access = Token.generate(:access) + now = DateTime.utc_now() + + Repo.insert!( + AccessToken.changeset(%{ + access_token_hash: access.hash, + access_token_prefix: access.prefix, + client_id: + Keyword.get(opts, :client_id, "https://claude.ai/oauth/claude-code-client-metadata"), + client_name: Keyword.get(opts, :client_name, "Claude Code"), + scopes: ["stats:read:*", "sites:read:*"], + user_id: user.id, + team_id: team.id, + access_token_expires_at: DateTime.add(now, 3600, :second), + refresh_token_expires_at: DateTime.add(now, 86_400, :second) + }) + ) + end + + describe "GET /settings/security - connected applications" do + test "lists the user's grants with a revoke link", %{conn: conn, user: user} do + grant = insert_grant(user) + + html = conn |> get(Routes.settings_path(conn, :security)) |> html_response(200) + + assert html =~ "Connected applications" + # Human-readable name, with the CIMD URL shown as a secondary line. + assert html =~ "Claude Code" + assert html =~ "https://claude.ai/oauth/claude-code-client-metadata" + assert html =~ Routes.settings_path(conn, :revoke_oauth_connector, grant.id) + end + + test "section is hidden when there are no grants and the flag is off", %{conn: conn} do + html = conn |> get(Routes.settings_path(conn, :security)) |> html_response(200) + refute html =~ "Connected applications" + end + end + + describe "DELETE /settings/security/oauth-connectors/:id" do + test "revokes the grant", %{conn: conn, user: user} do + grant = insert_grant(user) + + conn = delete(conn, Routes.settings_path(conn, :revoke_oauth_connector, grant.id)) + + assert redirected_to(conn) == Routes.settings_path(conn, :security) <> "#oauth-connectors" + assert Plausible.OAuth.list_grants(user) == [] + end + + test "cannot revoke another user's grant", %{conn: conn, user: user} do + other = new_user() + grant = insert_grant(other) + + conn = delete(conn, Routes.settings_path(conn, :revoke_oauth_connector, grant.id)) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "not found" + assert [_] = Plausible.OAuth.list_grants(other) + # The current user's own list is unaffected. + assert Plausible.OAuth.list_grants(user) == [] + end + end +end diff --git a/test/plausible_web/plugs/authorize_oauth_api_test.exs b/test/plausible_web/plugs/authorize_oauth_api_test.exs new file mode 100644 index 000000000000..1d6982dd349b --- /dev/null +++ b/test/plausible_web/plugs/authorize_oauth_api_test.exs @@ -0,0 +1,146 @@ +defmodule PlausibleWeb.Plugs.AuthorizeOAuthAPITest do + use PlausibleWeb.ConnCase, async: false + + alias PlausibleWeb.Plugs.AuthorizeOAuthAPI + alias Plausible.OAuth + alias Plausible.OAuth.{AccessToken, Token} + alias Plausible.Repo + + @redirect_uri "https://client.example/callback" + @client_id "https://client.example/oauth-metadata" + @resource "https://plausible.example/mcp" + + setup %{conn: conn} do + user = new_user() + {:ok, team} = Plausible.Teams.get_or_create(user) + {:ok, conn: prepare_conn_for_auth(conn), user: user, team: team} + end + + test "halts 401 with a discovery WWW-Authenticate header when no token is provided", %{ + conn: conn + } do + conn = conn |> get("/") |> AuthorizeOAuthAPI.call(nil) + + assert conn.halted + assert json_response(conn, 401)["error"] == "unauthorized" + + [header] = get_resp_header(conn, "www-authenticate") + assert header =~ "Bearer resource_metadata=" + assert header =~ "/.well-known/oauth-protected-resource" + refute header =~ "error=" + end + + test "halts 401 invalid_token for a bogus bearer token", %{conn: conn} do + conn = call_with_token(conn, "not-a-real-token") + + assert conn.halted + assert json_response(conn, 401)["error"] == "invalid_token" + + [header] = get_resp_header(conn, "www-authenticate") + assert header =~ ~s(error="invalid_token") + end + + test "halts 401 invalid_token for an expired token", %{conn: conn, user: user, team: team} do + raw = insert_expired_token(user, team, ["stats:read:*"]) + conn = call_with_token(conn, raw) + + assert conn.halted + assert json_response(conn, 401)["error"] == "invalid_token" + end + + test "passes and assigns the identity + granted scopes for a valid token", %{ + conn: conn, + user: user, + team: team + } do + %{access_token: raw} = issue_tokens(user, team, ["stats:read:*", "sites:read:*"]) + conn = call_with_token(conn, raw) + + refute conn.halted + assert conn.assigns.current_user.id == user.id + assert conn.assigns.current_team.id == team.id + assert conn.assigns.oauth_scopes == ["stats:read:*", "sites:read:*"] + end + + test "stamps last_used_at on the token when it authenticates", %{ + conn: conn, + user: user, + team: team + } do + %{access_token: raw} = issue_tokens(user, team, ["stats:read:*"]) + + refute call_with_token(conn, raw).halted + + assert {:ok, token} = OAuth.find_access_token(raw) + assert token.last_used_at + end + + test "halts 429 when over the burst request limit", %{conn: _conn} do + patch_env(Plausible.Auth.ApiKey, burst_request_limit: 3, burst_period_seconds: 60) + + user = new_user(team: [hourly_api_request_limit: 1_000]) + team = team_of(user) + %{access_token: raw} = issue_tokens(user, team, ["stats:read:*"]) + + limit = Plausible.Auth.ApiKey.burst_request_limit() + + # A fixed-window limiter lets up to `2 * limit` through near a boundary, so + # only the `2 * limit + 1`th request is guaranteed to be throttled. + conns = for _ <- 1..(2 * limit + 1), do: call_with_token(get_fresh_conn(), raw) + + assert throttled = Enum.find(conns, & &1.halted) + assert json_response(throttled, 429)["error"] == "too_many_requests" + end + + defp prepare_conn_for_auth(conn) do + conn + |> put_private(PlausibleWeb.FirstLaunchPlug, :skip) + |> bypass_through(PlausibleWeb.Router) + end + + defp get_fresh_conn(), do: build_conn() |> prepare_conn_for_auth() + + defp call_with_token(conn, token) do + conn + |> put_req_header("authorization", "Bearer #{token}") + |> get("/") + |> AuthorizeOAuthAPI.call(nil) + end + + defp issue_tokens(user, team, scopes) do + verifier = :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false) + challenge = :crypto.hash(:sha256, verifier) |> Base.url_encode64(padding: false) + + attrs = %{ + client_id: @client_id, + redirect_uri: @redirect_uri, + code_challenge: challenge, + code_challenge_method: "S256", + scopes: scopes, + resource: @resource + } + + {:ok, raw} = OAuth.create_authorization_code(user, team, attrs) + {:ok, code} = OAuth.consume_authorization_code(raw, verifier, @redirect_uri, @resource) + {:ok, tokens} = OAuth.issue_tokens(code) + tokens + end + + defp insert_expired_token(user, team, scopes) do + access = Token.generate(:access) + + Repo.insert!( + AccessToken.changeset(%{ + access_token_hash: access.hash, + access_token_prefix: access.prefix, + client_id: @client_id, + scopes: scopes, + user_id: user.id, + team_id: team.id, + access_token_expires_at: DateTime.add(DateTime.utc_now(), -60, :second) + }) + ) + + access.raw + end +end diff --git a/test/workers/oauth_cleanup_test.exs b/test/workers/oauth_cleanup_test.exs new file mode 100644 index 000000000000..f5ae24ebd0bb --- /dev/null +++ b/test/workers/oauth_cleanup_test.exs @@ -0,0 +1,45 @@ +defmodule Plausible.Workers.OAuthCleanupTest do + use Plausible.DataCase, async: true + + alias Plausible.OAuth.{AccessToken, AuthorizationCode, Token} + alias Plausible.Workers.OAuthCleanup + + test "purges expired authorization codes and fully-expired tokens" do + user = new_user() + {:ok, team} = Plausible.Teams.get_or_create(user) + now = DateTime.utc_now() + + code = Token.generate(:code) + + Repo.insert!( + AuthorizationCode.changeset(%{ + code_hash: code.hash, + client_id: "https://client.example/meta", + redirect_uri: "https://client.example/cb", + code_challenge: "x", + code_challenge_method: "S256", + user_id: user.id, + team_id: team.id, + expires_at: DateTime.add(now, -60, :second) + }) + ) + + token = Token.generate(:access) + + Repo.insert!( + AccessToken.changeset(%{ + access_token_hash: token.hash, + access_token_prefix: token.prefix, + client_id: "https://client.example/meta", + user_id: user.id, + team_id: team.id, + access_token_expires_at: DateTime.add(now, -120, :second), + refresh_token_expires_at: DateTime.add(now, -60, :second) + }) + ) + + assert :ok = OAuthCleanup.perform(%Oban.Job{}) + assert Repo.aggregate(AuthorizationCode, :count) == 0 + assert Repo.aggregate(AccessToken, :count) == 0 + end +end