From 33cf49e860c8c3a14c32f0b37b4432e86ee2f433 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 13 Jun 2025 10:17:27 -0700 Subject: [PATCH 1/3] Resurrect MRF.QuietReply This was not working correctly because the Publisher was stripping the public address from the cc when federating unlisted activities --- .../web/activity_pub/mrf/quiet_reply.ex | 62 ++++++++ .../web/activity_pub/mrf/quiet_reply_test.exs | 140 ++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 lib/pleroma/web/activity_pub/mrf/quiet_reply.ex create mode 100644 test/pleroma/web/activity_pub/mrf/quiet_reply_test.exs diff --git a/lib/pleroma/web/activity_pub/mrf/quiet_reply.ex b/lib/pleroma/web/activity_pub/mrf/quiet_reply.ex new file mode 100644 index 000000000..66080c47d --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/quiet_reply.ex @@ -0,0 +1,62 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2023 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.QuietReply do + @moduledoc """ + QuietReply alters the scope of activities from local users when replying by enforcing them to be "Unlisted" or "Quiet Public". This delivers the activity to all the expected recipients and instances, but it will not be published in the Federated / The Whole Known Network timelines. It will still be published to the Home timelines of the user's followers and visible to anyone who opens the thread. + """ + require Pleroma.Constants + + alias Pleroma.User + + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + @impl true + def history_awareness, do: :auto + + @impl true + def filter( + %{ + "type" => "Create", + "to" => to, + "cc" => cc, + "object" => %{ + "actor" => actor, + "type" => "Note", + "inReplyTo" => in_reply_to + } + } = activity + ) do + with true <- is_binary(in_reply_to), + false <- match?([], cc), + %User{follower_address: followers_collection, local: true} <- + User.get_by_ap_id(actor) do + updated_to = + to + |> Kernel.++([followers_collection]) + |> Kernel.--([Pleroma.Constants.as_public()]) + + updated_cc = + [Pleroma.Constants.as_public() | cc] + |> Kernel.--([followers_collection]) + + updated_activity = + activity + |> Map.put("to", updated_to) + |> Map.put("cc", updated_cc) + |> put_in(["object", "to"], updated_to) + |> put_in(["object", "cc"], updated_cc) + + {:ok, updated_activity} + else + _ -> {:ok, activity} + end + end + + @impl true + def filter(activity), do: {:ok, activity} + + @impl true + def describe, do: {:ok, %{}} +end diff --git a/test/pleroma/web/activity_pub/mrf/quiet_reply_test.exs b/test/pleroma/web/activity_pub/mrf/quiet_reply_test.exs new file mode 100644 index 000000000..79e64d650 --- /dev/null +++ b/test/pleroma/web/activity_pub/mrf/quiet_reply_test.exs @@ -0,0 +1,140 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.QuietReplyTest do + use Pleroma.DataCase + import Pleroma.Factory + + require Pleroma.Constants + + alias Pleroma.Object + alias Pleroma.Web.ActivityPub.MRF.QuietReply + alias Pleroma.Web.CommonAPI + + test "replying to public post is forced to be quiet" do + batman = insert(:user, nickname: "batman") + robin = insert(:user, nickname: "robin") + + {:ok, post} = CommonAPI.post(batman, %{status: "To the Batmobile!"}) + + reply = %{ + "type" => "Create", + "actor" => robin.ap_id, + "to" => [ + batman.ap_id, + Pleroma.Constants.as_public() + ], + "cc" => [robin.follower_address], + "object" => %{ + "type" => "Note", + "actor" => robin.ap_id, + "content" => "@batman Wait up, I forgot my spandex!", + "to" => [ + batman.ap_id, + Pleroma.Constants.as_public() + ], + "cc" => [robin.follower_address], + "inReplyTo" => Object.normalize(post).data["id"] + } + } + + expected_to = [batman.ap_id, robin.follower_address] + expected_cc = [Pleroma.Constants.as_public()] + + assert {:ok, filtered} = QuietReply.filter(reply) + + assert expected_to == filtered["to"] + assert expected_cc == filtered["cc"] + assert expected_to == filtered["object"]["to"] + assert expected_cc == filtered["object"]["cc"] + end + + test "replying to unlisted post is unmodified" do + batman = insert(:user, nickname: "batman") + robin = insert(:user, nickname: "robin") + + {:ok, post} = CommonAPI.post(batman, %{status: "To the Batmobile!", visibility: "private"}) + + reply = %{ + "type" => "Create", + "actor" => robin.ap_id, + "to" => [batman.ap_id], + "cc" => [], + "object" => %{ + "type" => "Note", + "actor" => robin.ap_id, + "content" => "@batman Wait up, I forgot my spandex!", + "to" => [batman.ap_id], + "cc" => [], + "inReplyTo" => Object.normalize(post).data["id"] + } + } + + assert {:ok, filtered} = QuietReply.filter(reply) + + assert match?(^filtered, reply) + end + + test "replying direct is unmodified" do + batman = insert(:user, nickname: "batman") + robin = insert(:user, nickname: "robin") + + {:ok, post} = CommonAPI.post(batman, %{status: "To the Batmobile!"}) + + reply = %{ + "type" => "Create", + "actor" => robin.ap_id, + "to" => [batman.ap_id], + "cc" => [], + "object" => %{ + "type" => "Note", + "actor" => robin.ap_id, + "content" => "@batman Wait up, I forgot my spandex!", + "to" => [batman.ap_id], + "cc" => [], + "inReplyTo" => Object.normalize(post).data["id"] + } + } + + assert {:ok, filtered} = QuietReply.filter(reply) + + assert match?(^filtered, reply) + end + + test "replying followers-only is unmodified" do + batman = insert(:user, nickname: "batman") + robin = insert(:user, nickname: "robin") + + {:ok, post} = CommonAPI.post(batman, %{status: "To the Batmobile!"}) + + reply = %{ + "type" => "Create", + "actor" => robin.ap_id, + "to" => [batman.ap_id, robin.follower_address], + "cc" => [], + "object" => %{ + "type" => "Note", + "actor" => robin.ap_id, + "content" => "@batman Wait up, I forgot my spandex!", + "to" => [batman.ap_id, robin.follower_address], + "cc" => [], + "inReplyTo" => Object.normalize(post).data["id"] + } + } + + assert {:ok, filtered} = QuietReply.filter(reply) + + assert match?(^filtered, reply) + end + + test "non-reply posts are unmodified" do + batman = insert(:user, nickname: "batman") + + {:ok, post} = CommonAPI.post(batman, %{status: "To the Batmobile!"}) + + assert {:ok, filtered} = QuietReply.filter(post) + + assert match?(^filtered, post) + end +end From 37d4ed883c1b042df12facaba82d44929008b918 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 19 Jun 2025 14:50:45 -0700 Subject: [PATCH 2/3] Change MRF logic to match when there is an inReplyTo and the public address is in the "to" field Update the method to alter the to/cc fields for consistency and modify the tests to work without requiring a specific order items in the list --- .../web/activity_pub/mrf/quiet_reply.ex | 5 +- .../web/activity_pub/mrf/quiet_reply_test.exs | 13 +- .../o_auth/oauth_authorization_flow_test.exs | 339 ++++++++++++++++++ 3 files changed, 347 insertions(+), 10 deletions(-) create mode 100644 test/pleroma/web/o_auth/oauth_authorization_flow_test.exs diff --git a/lib/pleroma/web/activity_pub/mrf/quiet_reply.ex b/lib/pleroma/web/activity_pub/mrf/quiet_reply.ex index 66080c47d..b3eb0b390 100644 --- a/lib/pleroma/web/activity_pub/mrf/quiet_reply.ex +++ b/lib/pleroma/web/activity_pub/mrf/quiet_reply.ex @@ -29,12 +29,11 @@ defmodule Pleroma.Web.ActivityPub.MRF.QuietReply do } = activity ) do with true <- is_binary(in_reply_to), - false <- match?([], cc), + true <- Pleroma.Constants.as_public() in to, %User{follower_address: followers_collection, local: true} <- User.get_by_ap_id(actor) do updated_to = - to - |> Kernel.++([followers_collection]) + [followers_collection | to] |> Kernel.--([Pleroma.Constants.as_public()]) updated_cc = diff --git a/test/pleroma/web/activity_pub/mrf/quiet_reply_test.exs b/test/pleroma/web/activity_pub/mrf/quiet_reply_test.exs index 79e64d650..f66383bf5 100644 --- a/test/pleroma/web/activity_pub/mrf/quiet_reply_test.exs +++ b/test/pleroma/web/activity_pub/mrf/quiet_reply_test.exs @@ -39,15 +39,14 @@ defmodule Pleroma.Web.ActivityPub.MRF.QuietReplyTest do } } - expected_to = [batman.ap_id, robin.follower_address] - expected_cc = [Pleroma.Constants.as_public()] - assert {:ok, filtered} = QuietReply.filter(reply) - assert expected_to == filtered["to"] - assert expected_cc == filtered["cc"] - assert expected_to == filtered["object"]["to"] - assert expected_cc == filtered["object"]["cc"] + assert batman.ap_id in filtered["to"] + assert batman.ap_id in filtered["object"]["to"] + assert robin.follower_address in filtered["to"] + assert robin.follower_address in filtered["object"]["to"] + assert Pleroma.Constants.as_public() in filtered["cc"] + assert Pleroma.Constants.as_public() in filtered["object"]["cc"] end test "replying to unlisted post is unmodified" do diff --git a/test/pleroma/web/o_auth/oauth_authorization_flow_test.exs b/test/pleroma/web/o_auth/oauth_authorization_flow_test.exs new file mode 100644 index 000000000..fdd8cbdb4 --- /dev/null +++ b/test/pleroma/web/o_auth/oauth_authorization_flow_test.exs @@ -0,0 +1,339 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.OAuth.OAuthAuthorizationFlowTest do + use Pleroma.Web.ConnCase + + import Pleroma.Factory + + alias Pleroma.Helpers.AuthHelper + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web.OAuth.App + alias Pleroma.Web.OAuth.Authorization + alias Pleroma.Web.OAuth.OAuthController + alias Pleroma.Web.OAuth.Token + + @session_opts [ + store: :cookie, + key: "_test", + signing_salt: "cooldude" + ] + + setup do + clear_config([:instance, :account_activation_required], false) + clear_config([:instance, :account_approval_required], false) + end + + describe "OAuth authorization flow with external integration" do + test "complete OAuth flow: create user, create app, authorize, get token, use token" do + # Step 1: Create a user + user = insert(:user, password_hash: Pleroma.Password.Pbkdf2.hash_pwd_salt("test")) + + # Step 2: Create a new OAuth client with the required scopes + app = + insert(:oauth_app, + scopes: ["read", "write", "follow", "push"], + redirect_uris: "urn:ietf:wg:oauth:2.0:oob" + ) + + # Step 3: Set up a logged in session + conn = + build_conn() + |> Plug.Session.call(Plug.Session.init(@session_opts)) + |> fetch_session() + |> AuthHelper.put_session_token(insert(:oauth_token, user: user).token) + + # Step 4: Access the /oauth/authorize endpoint with the specified parameters + authorize_params = %{ + "client_id" => app.client_id, + "response_type" => "code", + "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", + "scope" => "read write follow push", + "force_login" => "False", + "state" => "None", + "lang" => "None" + } + + # First, get the authorization page + conn = get(conn, "/oauth/authorize", authorize_params) + assert html_response(conn, 200) + + # Step 5: Submit the authorization (simulate user approving the app) + authorization_data = %{ + "authorization" => %{ + "client_id" => app.client_id, + "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", + "scope" => "read write follow push", + "state" => "None" + } + } + + conn = post(conn, "/oauth/authorize", authorization_data) + + # Should get the OOB authorization page with the code + assert html_response(conn, 200) + + # Extract the authorization code from the response + response = html_response(conn, 200) + assert response =~ "Successfully authorized" + assert response =~ "Token code is" + + # Parse the authorization code from the response + code_match = Regex.run(~r/Token code is
([a-zA-Z0-9_-]+)/, response) + assert code_match + [_, authorization_code] = code_match + + # Step 6: Exchange the authorization code for an access token + token_conn = + build_conn() + |> post("/oauth/token", %{ + "grant_type" => "authorization_code", + "code" => authorization_code, + "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", + "client_id" => app.client_id, + "client_secret" => app.client_secret + }) + + token_response = json_response(token_conn, 200) + assert %{"access_token" => access_token, "token_type" => "Bearer"} = token_response + assert token_response["scope"] == "read write follow push" + + # Verify the token was created in the database + token_record = Repo.get_by(Token, token: access_token) + assert token_record + assert token_record.scopes == ["read", "write", "follow", "push"] + assert token_record.user_id == user.id + assert token_record.app_id == app.id + + # Step 7: Use the token to access a protected endpoint + protected_conn = + build_conn() + |> put_req_header("authorization", "Bearer #{access_token}") + |> get("/api/v1/accounts/verify_credentials") + + # Should get a 200 response with user information + user_info = json_response(protected_conn, 200) + assert user_info["id"] == to_string(user.id) + assert user_info["username"] == user.nickname + assert user_info["acct"] == user.nickname + + # Step 8: Test that the token has the correct scopes by accessing different endpoints + # Test read:accounts scope (should work) + conn_with_token = + build_conn() + |> put_req_header("authorization", "Bearer #{access_token}") + + # This should work because we have "read" scope + conn_with_token + |> get("/api/v1/accounts/#{user.id}") + |> json_response(200) + + # Test write:accounts scope (should work) - with proper content-type + conn_with_token + |> put_req_header("content-type", "application/json") + |> patch("/api/v1/accounts/update_credentials", %{"display_name" => "Test Name"}) + |> json_response(200) + + # Test that the token is properly associated with the user + assert token_record.user_id == user.id + assert token_record.app_id == app.id + end + + test "OAuth flow with force_login=false and existing session" do + # Create a user and app + user = insert(:user, password_hash: Pleroma.Password.Pbkdf2.hash_pwd_salt("test")) + + app = + insert(:oauth_app, + scopes: ["read", "write", "follow", "push"], + redirect_uris: "urn:ietf:wg:oauth:2.0:oob" + ) + + # Create an existing token for the same user and app + existing_token = insert(:oauth_token, user: user, app: app, scopes: ["read", "write"]) + + # Set up a logged in session with the existing token + conn = + build_conn() + |> Plug.Session.call(Plug.Session.init(@session_opts)) + |> fetch_session() + |> AuthHelper.put_session_token(existing_token.token) + + # Access the authorize endpoint with force_login=false + authorize_params = %{ + "client_id" => app.client_id, + "response_type" => "code", + "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", + "scope" => "read write follow push", + "force_login" => "False", + "state" => "test_state" + } + + # Should redirect to the OOB page with the existing token + conn = get(conn, "/oauth/authorize", authorize_params) + assert html_response(conn, 200) + assert html_response(conn, 200) =~ "Authorization exists" + end + + test "OAuth flow with different scopes than existing token" do + # Create a user and app + user = insert(:user, password_hash: Pleroma.Password.Pbkdf2.hash_pwd_salt("test")) + + app = + insert(:oauth_app, + scopes: ["read", "write", "follow", "push"], + redirect_uris: "urn:ietf:wg:oauth:2.0:oob" + ) + + # Create an existing token with different scopes + existing_token = insert(:oauth_token, user: user, app: app, scopes: ["read"]) + + # Set up a logged in session + conn = + build_conn() + |> Plug.Session.call(Plug.Session.init(@session_opts)) + |> fetch_session() + |> AuthHelper.put_session_token(existing_token.token) + + # Access the authorize endpoint requesting more scopes + authorize_params = %{ + "client_id" => app.client_id, + "response_type" => "code", + "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", + "scope" => "read write follow push", + "force_login" => "False", + "state" => "test_state" + } + + # Should show the authorization page because scopes are different + conn = get(conn, "/oauth/authorize", authorize_params) + assert html_response(conn, 200) + assert html_response(conn, 200) =~ "Authorization exists" + end + + test "OAuth flow with invalid client_id" do + user = insert(:user, password_hash: Pleroma.Password.Pbkdf2.hash_pwd_salt("test")) + + conn = + build_conn() + |> Plug.Session.call(Plug.Session.init(@session_opts)) + |> fetch_session() + |> AuthHelper.put_session_token(insert(:oauth_token, user: user).token) + + # Try to authorize with invalid client_id + authorize_params = %{ + "client_id" => "invalid_client_id", + "response_type" => "code", + "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", + "scope" => "read write follow push", + "force_login" => "False" + } + + conn = get(conn, "/oauth/authorize", authorize_params) + # Should still render the page but with error or missing app info + assert html_response(conn, 200) + end + + test "OAuth flow with unlisted redirect_uri" do + user = insert(:user, password_hash: Pleroma.Password.Pbkdf2.hash_pwd_salt("test")) + + app = + insert(:oauth_app, + scopes: ["read", "write", "follow", "push"], + # Different from requested + redirect_uris: "https://example.com/callback" + ) + + conn = + build_conn() + |> Plug.Session.call(Plug.Session.init(@session_opts)) + |> fetch_session() + |> AuthHelper.put_session_token(insert(:oauth_token, user: user).token) + + # Try to authorize with unlisted redirect_uri + authorize_params = %{ + "client_id" => app.client_id, + "response_type" => "code", + "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", + "scope" => "read write follow push", + "force_login" => "False" + } + + conn = get(conn, "/oauth/authorize", authorize_params) + # Should still render the page but with error about unlisted redirect_uri + assert html_response(conn, 200) + end + + test "OAuth flow with expired authorization code" do + user = insert(:user, password_hash: Pleroma.Password.Pbkdf2.hash_pwd_salt("test")) + + app = + insert(:oauth_app, + scopes: ["read", "write", "follow", "push"], + redirect_uris: "urn:ietf:wg:oauth:2.0:oob" + ) + + # Create an expired authorization + expired_auth = + insert(:oauth_authorization, + user: user, + app: app, + # 1 hour ago + valid_until: NaiveDateTime.add(NaiveDateTime.utc_now(), -3600), + scopes: ["read", "write", "follow", "push"] + ) + + # Try to exchange expired code for token + conn = + build_conn() + |> post("/oauth/token", %{ + "grant_type" => "authorization_code", + "code" => expired_auth.token, + "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", + "client_id" => app.client_id, + "client_secret" => app.client_secret + }) + + # Should get an error + response = json_response(conn, 400) + assert %{"error" => _} = response + end + + test "OAuth flow with used authorization code" do + user = insert(:user, password_hash: Pleroma.Password.Pbkdf2.hash_pwd_salt("test")) + + app = + insert(:oauth_app, + scopes: ["read", "write", "follow", "push"], + redirect_uris: "urn:ietf:wg:oauth:2.0:oob" + ) + + # Create an authorization and mark it as used + auth = + insert(:oauth_authorization, + user: user, + app: app, + scopes: ["read", "write", "follow", "push"] + ) + + {:ok, _} = Authorization.use_token(auth) + + # Try to exchange used code for token + conn = + build_conn() + |> post("/oauth/token", %{ + "grant_type" => "authorization_code", + "code" => auth.token, + "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", + "client_id" => app.client_id, + "client_secret" => app.client_secret + }) + + # Should get an error + response = json_response(conn, 400) + assert %{"error" => _} = response + end + end +end From 81155a229216b2d58c63f5a0f418d1354ced6990 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 19 Jun 2025 14:53:37 -0700 Subject: [PATCH 3/3] changelog for MRF.QuietReply --- changelog.d/mrf-quietreply.add | 1 + .../o_auth/oauth_authorization_flow_test.exs | 339 ------------------ 2 files changed, 1 insertion(+), 339 deletions(-) create mode 100644 changelog.d/mrf-quietreply.add delete mode 100644 test/pleroma/web/o_auth/oauth_authorization_flow_test.exs diff --git a/changelog.d/mrf-quietreply.add b/changelog.d/mrf-quietreply.add new file mode 100644 index 000000000..4ed20bce6 --- /dev/null +++ b/changelog.d/mrf-quietreply.add @@ -0,0 +1 @@ +Added MRF.QuietReply which prevents replies to public posts from being published to the timelines diff --git a/test/pleroma/web/o_auth/oauth_authorization_flow_test.exs b/test/pleroma/web/o_auth/oauth_authorization_flow_test.exs deleted file mode 100644 index fdd8cbdb4..000000000 --- a/test/pleroma/web/o_auth/oauth_authorization_flow_test.exs +++ /dev/null @@ -1,339 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2022 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.OAuth.OAuthAuthorizationFlowTest do - use Pleroma.Web.ConnCase - - import Pleroma.Factory - - alias Pleroma.Helpers.AuthHelper - alias Pleroma.Repo - alias Pleroma.User - alias Pleroma.Web.OAuth.App - alias Pleroma.Web.OAuth.Authorization - alias Pleroma.Web.OAuth.OAuthController - alias Pleroma.Web.OAuth.Token - - @session_opts [ - store: :cookie, - key: "_test", - signing_salt: "cooldude" - ] - - setup do - clear_config([:instance, :account_activation_required], false) - clear_config([:instance, :account_approval_required], false) - end - - describe "OAuth authorization flow with external integration" do - test "complete OAuth flow: create user, create app, authorize, get token, use token" do - # Step 1: Create a user - user = insert(:user, password_hash: Pleroma.Password.Pbkdf2.hash_pwd_salt("test")) - - # Step 2: Create a new OAuth client with the required scopes - app = - insert(:oauth_app, - scopes: ["read", "write", "follow", "push"], - redirect_uris: "urn:ietf:wg:oauth:2.0:oob" - ) - - # Step 3: Set up a logged in session - conn = - build_conn() - |> Plug.Session.call(Plug.Session.init(@session_opts)) - |> fetch_session() - |> AuthHelper.put_session_token(insert(:oauth_token, user: user).token) - - # Step 4: Access the /oauth/authorize endpoint with the specified parameters - authorize_params = %{ - "client_id" => app.client_id, - "response_type" => "code", - "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", - "scope" => "read write follow push", - "force_login" => "False", - "state" => "None", - "lang" => "None" - } - - # First, get the authorization page - conn = get(conn, "/oauth/authorize", authorize_params) - assert html_response(conn, 200) - - # Step 5: Submit the authorization (simulate user approving the app) - authorization_data = %{ - "authorization" => %{ - "client_id" => app.client_id, - "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", - "scope" => "read write follow push", - "state" => "None" - } - } - - conn = post(conn, "/oauth/authorize", authorization_data) - - # Should get the OOB authorization page with the code - assert html_response(conn, 200) - - # Extract the authorization code from the response - response = html_response(conn, 200) - assert response =~ "Successfully authorized" - assert response =~ "Token code is" - - # Parse the authorization code from the response - code_match = Regex.run(~r/Token code is
([a-zA-Z0-9_-]+)/, response) - assert code_match - [_, authorization_code] = code_match - - # Step 6: Exchange the authorization code for an access token - token_conn = - build_conn() - |> post("/oauth/token", %{ - "grant_type" => "authorization_code", - "code" => authorization_code, - "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", - "client_id" => app.client_id, - "client_secret" => app.client_secret - }) - - token_response = json_response(token_conn, 200) - assert %{"access_token" => access_token, "token_type" => "Bearer"} = token_response - assert token_response["scope"] == "read write follow push" - - # Verify the token was created in the database - token_record = Repo.get_by(Token, token: access_token) - assert token_record - assert token_record.scopes == ["read", "write", "follow", "push"] - assert token_record.user_id == user.id - assert token_record.app_id == app.id - - # Step 7: Use the token to access a protected endpoint - protected_conn = - build_conn() - |> put_req_header("authorization", "Bearer #{access_token}") - |> get("/api/v1/accounts/verify_credentials") - - # Should get a 200 response with user information - user_info = json_response(protected_conn, 200) - assert user_info["id"] == to_string(user.id) - assert user_info["username"] == user.nickname - assert user_info["acct"] == user.nickname - - # Step 8: Test that the token has the correct scopes by accessing different endpoints - # Test read:accounts scope (should work) - conn_with_token = - build_conn() - |> put_req_header("authorization", "Bearer #{access_token}") - - # This should work because we have "read" scope - conn_with_token - |> get("/api/v1/accounts/#{user.id}") - |> json_response(200) - - # Test write:accounts scope (should work) - with proper content-type - conn_with_token - |> put_req_header("content-type", "application/json") - |> patch("/api/v1/accounts/update_credentials", %{"display_name" => "Test Name"}) - |> json_response(200) - - # Test that the token is properly associated with the user - assert token_record.user_id == user.id - assert token_record.app_id == app.id - end - - test "OAuth flow with force_login=false and existing session" do - # Create a user and app - user = insert(:user, password_hash: Pleroma.Password.Pbkdf2.hash_pwd_salt("test")) - - app = - insert(:oauth_app, - scopes: ["read", "write", "follow", "push"], - redirect_uris: "urn:ietf:wg:oauth:2.0:oob" - ) - - # Create an existing token for the same user and app - existing_token = insert(:oauth_token, user: user, app: app, scopes: ["read", "write"]) - - # Set up a logged in session with the existing token - conn = - build_conn() - |> Plug.Session.call(Plug.Session.init(@session_opts)) - |> fetch_session() - |> AuthHelper.put_session_token(existing_token.token) - - # Access the authorize endpoint with force_login=false - authorize_params = %{ - "client_id" => app.client_id, - "response_type" => "code", - "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", - "scope" => "read write follow push", - "force_login" => "False", - "state" => "test_state" - } - - # Should redirect to the OOB page with the existing token - conn = get(conn, "/oauth/authorize", authorize_params) - assert html_response(conn, 200) - assert html_response(conn, 200) =~ "Authorization exists" - end - - test "OAuth flow with different scopes than existing token" do - # Create a user and app - user = insert(:user, password_hash: Pleroma.Password.Pbkdf2.hash_pwd_salt("test")) - - app = - insert(:oauth_app, - scopes: ["read", "write", "follow", "push"], - redirect_uris: "urn:ietf:wg:oauth:2.0:oob" - ) - - # Create an existing token with different scopes - existing_token = insert(:oauth_token, user: user, app: app, scopes: ["read"]) - - # Set up a logged in session - conn = - build_conn() - |> Plug.Session.call(Plug.Session.init(@session_opts)) - |> fetch_session() - |> AuthHelper.put_session_token(existing_token.token) - - # Access the authorize endpoint requesting more scopes - authorize_params = %{ - "client_id" => app.client_id, - "response_type" => "code", - "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", - "scope" => "read write follow push", - "force_login" => "False", - "state" => "test_state" - } - - # Should show the authorization page because scopes are different - conn = get(conn, "/oauth/authorize", authorize_params) - assert html_response(conn, 200) - assert html_response(conn, 200) =~ "Authorization exists" - end - - test "OAuth flow with invalid client_id" do - user = insert(:user, password_hash: Pleroma.Password.Pbkdf2.hash_pwd_salt("test")) - - conn = - build_conn() - |> Plug.Session.call(Plug.Session.init(@session_opts)) - |> fetch_session() - |> AuthHelper.put_session_token(insert(:oauth_token, user: user).token) - - # Try to authorize with invalid client_id - authorize_params = %{ - "client_id" => "invalid_client_id", - "response_type" => "code", - "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", - "scope" => "read write follow push", - "force_login" => "False" - } - - conn = get(conn, "/oauth/authorize", authorize_params) - # Should still render the page but with error or missing app info - assert html_response(conn, 200) - end - - test "OAuth flow with unlisted redirect_uri" do - user = insert(:user, password_hash: Pleroma.Password.Pbkdf2.hash_pwd_salt("test")) - - app = - insert(:oauth_app, - scopes: ["read", "write", "follow", "push"], - # Different from requested - redirect_uris: "https://example.com/callback" - ) - - conn = - build_conn() - |> Plug.Session.call(Plug.Session.init(@session_opts)) - |> fetch_session() - |> AuthHelper.put_session_token(insert(:oauth_token, user: user).token) - - # Try to authorize with unlisted redirect_uri - authorize_params = %{ - "client_id" => app.client_id, - "response_type" => "code", - "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", - "scope" => "read write follow push", - "force_login" => "False" - } - - conn = get(conn, "/oauth/authorize", authorize_params) - # Should still render the page but with error about unlisted redirect_uri - assert html_response(conn, 200) - end - - test "OAuth flow with expired authorization code" do - user = insert(:user, password_hash: Pleroma.Password.Pbkdf2.hash_pwd_salt("test")) - - app = - insert(:oauth_app, - scopes: ["read", "write", "follow", "push"], - redirect_uris: "urn:ietf:wg:oauth:2.0:oob" - ) - - # Create an expired authorization - expired_auth = - insert(:oauth_authorization, - user: user, - app: app, - # 1 hour ago - valid_until: NaiveDateTime.add(NaiveDateTime.utc_now(), -3600), - scopes: ["read", "write", "follow", "push"] - ) - - # Try to exchange expired code for token - conn = - build_conn() - |> post("/oauth/token", %{ - "grant_type" => "authorization_code", - "code" => expired_auth.token, - "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", - "client_id" => app.client_id, - "client_secret" => app.client_secret - }) - - # Should get an error - response = json_response(conn, 400) - assert %{"error" => _} = response - end - - test "OAuth flow with used authorization code" do - user = insert(:user, password_hash: Pleroma.Password.Pbkdf2.hash_pwd_salt("test")) - - app = - insert(:oauth_app, - scopes: ["read", "write", "follow", "push"], - redirect_uris: "urn:ietf:wg:oauth:2.0:oob" - ) - - # Create an authorization and mark it as used - auth = - insert(:oauth_authorization, - user: user, - app: app, - scopes: ["read", "write", "follow", "push"] - ) - - {:ok, _} = Authorization.use_token(auth) - - # Try to exchange used code for token - conn = - build_conn() - |> post("/oauth/token", %{ - "grant_type" => "authorization_code", - "code" => auth.token, - "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", - "client_id" => app.client_id, - "client_secret" => app.client_secret - }) - - # Should get an error - response = json_response(conn, 400) - assert %{"error" => _} = response - end - end -end