diff --git a/apps/api/.env.example b/apps/api/.env.example
index 6bed24003b4..586ae43fc43 100644
--- a/apps/api/.env.example
+++ b/apps/api/.env.example
@@ -79,3 +79,8 @@ API_KEY_RATE_LIMIT="60/minute"
# MPASS_BYPASS_PATHS=/god-mode,/api/instances
# Required for 3-layer logout (Django → oauth2-proxy → Cognito)
# MPASS_SIGNOUT_URL=https://foss-auth.local.moneta.dev/oauth2/sign_out?rd=https%3A%2F%2Fcognito.example.com%2Flogout
+# Root domain of the foss-server-bundle deployment. Used by
+# /auth/sign-out/?next= as the redirect allowlist — only URLs whose
+# host equals PLATFORM_DOMAIN or is a subdomain of it are followed.
+# Set automatically by foss-server-bundle/platform.sh.
+# PLATFORM_DOMAIN=foss.arbisoft.com
diff --git a/apps/api/plane/authentication/urls.py b/apps/api/plane/authentication/urls.py
index bc736087fcb..47f443be84d 100644
--- a/apps/api/plane/authentication/urls.py
+++ b/apps/api/plane/authentication/urls.py
@@ -33,7 +33,10 @@
# GiteaOauthInitiateSpaceEndpoint, GiteaCallbackSpaceEndpoint,
urlpatterns = [
- # signout — kept active (used by frontend 3-layer logout)
+ # signout — POST for in-app logout, GET (?next=) for the foss-bundle
+ # portal's "Log out of all apps" redirect chain. Both methods flush
+ # the Django session via the same django.contrib.auth.logout() call;
+ # GET is CSRF-exempt with PLATFORM_DOMAIN-bounded ?next= validation.
path("sign-out/", SignOutAuthEndpoint.as_view(), name="sign-out"),
path("spaces/sign-out/", SignOutAuthSpaceEndpoint.as_view(), name="space-sign-out"),
# csrf token — kept active (Django forms need it)
diff --git a/apps/api/plane/authentication/views/app/signout.py b/apps/api/plane/authentication/views/app/signout.py
index 65aa29aef4e..3774ea0d5e9 100644
--- a/apps/api/plane/authentication/views/app/signout.py
+++ b/apps/api/plane/authentication/views/app/signout.py
@@ -2,18 +2,24 @@
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
+# Standard library
+from urllib.parse import urlparse
+
# Django imports
from django.views import View
from django.contrib.auth import logout
from django.conf import settings
-from django.http import HttpResponseRedirect
+from django.http import HttpResponseBadRequest, HttpResponseRedirect
from django.utils import timezone
+from django.utils.decorators import method_decorator
+from django.views.decorators.csrf import csrf_exempt
# Module imports
from plane.authentication.utils.host import user_ip, base_host
from plane.db.models import User
+@method_decorator(csrf_exempt, name="dispatch")
class SignOutAuthEndpoint(View):
def post(self, request):
try:
@@ -36,3 +42,62 @@ def post(self, request):
return HttpResponseRedirect(mpass_signout_url)
return HttpResponseRedirect(base_host(request=request, is_app=True))
+
+ def get(self, request):
+ """
+ Cross-origin redirect-chain entry-point for the foss-server-bundle
+ portal's "Log out of all apps" flow.
+
+ Clears the Django session via django.contrib.auth.logout(), then
+ 302s to ?next= (validated against PLATFORM_DOMAIN to prevent open
+ redirect). The chain works because the browser is on this app's
+ own domain when this endpoint runs — so Django's Set-Cookie scope
+ is correct.
+
+ CSRF-exempt (set at class level): no token is shared cross-origin
+ with the portal. Residual risk is force-logout (attacker embeds
+
ending the victim's session); low impact —
+ only the session itself is lost, and re-auth via ForwardAuth is
+ automatic.
+ """
+ logout(request)
+
+ next_url = (request.GET.get("next") or "").strip()
+ if next_url:
+ if not self._is_allowed_next(next_url):
+ return HttpResponseBadRequest(
+ "next= target host is not a subdomain of PLATFORM_DOMAIN"
+ )
+ return HttpResponseRedirect(next_url)
+
+ # No ?next= — fall back to MPASS_SIGNOUT_URL so a manual hit still
+ # chains through oauth2-proxy + Cognito.
+ mpass_signout_url = getattr(settings, "MPASS_SIGNOUT_URL", None)
+ if mpass_signout_url:
+ return HttpResponseRedirect(mpass_signout_url)
+ return HttpResponseRedirect(base_host(request=request, is_app=True))
+
+ @staticmethod
+ def _is_allowed_next(url):
+ """True iff URL's host equals PLATFORM_DOMAIN or is a subdomain.
+
+ Suffix match enforces a dot boundary: foss.arbisoft.com matches
+ foss.arbisoft.com and *.foss.arbisoft.com but NOT
+ foss.arbisoft.com.evil. Unset PLATFORM_DOMAIN → False (every
+ ?next= rejected).
+ """
+ platform_domain = (
+ getattr(settings, "PLATFORM_DOMAIN", "") or ""
+ ).strip().lower().lstrip(".")
+ if not platform_domain:
+ return False
+
+ try:
+ host = urlparse(url).hostname
+ except ValueError:
+ return False
+ if not host:
+ return False
+
+ host = host.lower()
+ return host == platform_domain or host.endswith("." + platform_domain)
diff --git a/apps/api/plane/settings/common.py b/apps/api/plane/settings/common.py
index 6d23c23a8d7..d7c23634142 100644
--- a/apps/api/plane/settings/common.py
+++ b/apps/api/plane/settings/common.py
@@ -62,6 +62,12 @@
# mPass proxy auth
MPASS_BYPASS_PATHS = [p.strip() for p in os.environ.get("MPASS_BYPASS_PATHS", "").split(",") if p.strip()] or None
DEFAULT_EMAIL_DOMAIN = os.environ.get("DEFAULT_EMAIL_DOMAIN", "askii.ai")
+# Root domain of the foss-server-bundle deployment, set by
+# foss-server-bundle/platform.sh. All app subdomains hang off this. Used
+# by /auth/portal-sign-out/ as the ?next= redirect allowlist: only URLs
+# whose host equals PLATFORM_DOMAIN or is a subdomain of it are followed.
+# Unset → endpoint still flushes the Django session, just won't redirect.
+PLATFORM_DOMAIN = (os.environ.get("PLATFORM_DOMAIN", "") or "").strip().lower().lstrip(".")
# SMB portal hostname segment (landing / logout redirects) vs default Plane workspace slug
SMB_NAME = os.environ.get("SMB_NAME")
diff --git a/apps/api/plane/tests/unit/views/test_signout.py b/apps/api/plane/tests/unit/views/test_signout.py
index 6ba375e0988..44e2af67e0c 100644
--- a/apps/api/plane/tests/unit/views/test_signout.py
+++ b/apps/api/plane/tests/unit/views/test_signout.py
@@ -129,3 +129,125 @@ def test_falls_back_to_base_host_when_save_raises_and_no_mpass_url(
assert response.status_code == 302
assert response["Location"] == _BASE_HOST
+
+
+# GET /auth/sign-out/?next= — portal logout-chain entry point
+# ────────────────────────────────────────────────────────────────
+
+
+def _make_get_request(factory: RequestFactory, qs: str = "") -> MagicMock:
+ url = "/auth/sign-out/" + (f"?{qs}" if qs else "")
+ req = factory.get(url)
+ req.user = MagicMock(id="user-uuid-1234")
+ return req
+
+
+@pytest.mark.unit
+class TestSignOutAuthEndpointGet:
+ """The GET variant: portal-driven logout chain.
+
+ Same `_flush_session()` body as POST. Differences:
+ - ?next= allowlist validated against PLATFORM_DOMAIN (host equality
+ or dot-bounded subdomain).
+ - CSRF-exempt (set at the class level via @method_decorator).
+ - Falls back to MPASS_SIGNOUT_URL or base_host when ?next= is
+ missing / invalid.
+ """
+
+ @patch("plane.authentication.views.app.signout.base_host", return_value=_BASE_HOST)
+ @patch("plane.authentication.views.app.signout.logout")
+ @patch("plane.authentication.views.app.signout.User")
+ @patch("plane.authentication.views.app.signout.settings")
+ def test_redirects_to_allowlisted_next(
+ self, mock_settings, mock_user_cls, mock_logout, mock_base_host, factory, view
+ ):
+ mock_settings.PLATFORM_DOMAIN = "foss.arbisoft.com"
+ mock_user_cls.objects.get.return_value = MagicMock()
+ next_url = "https://docs.foss.arbisoft.com/auth/sign-out/"
+
+ response = view.get(_make_get_request(factory, f"next={next_url}"))
+
+ mock_logout.assert_called_once() # GET still flushes the session
+ assert response.status_code == 302
+ assert response["Location"] == next_url
+
+ @patch("plane.authentication.views.app.signout.base_host", return_value=_BASE_HOST)
+ @patch("plane.authentication.views.app.signout.logout")
+ @patch("plane.authentication.views.app.signout.User")
+ @patch("plane.authentication.views.app.signout.settings")
+ def test_rejects_next_on_disallowed_host(
+ self, mock_settings, mock_user_cls, mock_logout, mock_base_host, factory, view
+ ):
+ mock_settings.PLATFORM_DOMAIN = "foss.arbisoft.com"
+ mock_user_cls.objects.get.return_value = MagicMock()
+
+ response = view.get(
+ _make_get_request(factory, "next=https://evil.example/steal")
+ )
+
+ mock_logout.assert_called_once() # session still flushed
+ assert response.status_code == 400
+
+ @patch("plane.authentication.views.app.signout.base_host", return_value=_BASE_HOST)
+ @patch("plane.authentication.views.app.signout.logout")
+ @patch("plane.authentication.views.app.signout.User")
+ @patch("plane.authentication.views.app.signout.settings")
+ def test_suffix_match_enforces_dot_boundary(
+ self, mock_settings, mock_user_cls, mock_logout, mock_base_host, factory, view
+ ):
+ """foss.arbisoft.com.evil must not match foss.arbisoft.com."""
+ mock_settings.PLATFORM_DOMAIN = "foss.arbisoft.com"
+ mock_user_cls.objects.get.return_value = MagicMock()
+
+ response = view.get(
+ _make_get_request(factory, "next=https://foss.arbisoft.com.evil/x")
+ )
+
+ assert response.status_code == 400
+
+ @patch("plane.authentication.views.app.signout.base_host", return_value=_BASE_HOST)
+ @patch("plane.authentication.views.app.signout.logout")
+ @patch("plane.authentication.views.app.signout.User")
+ @patch("plane.authentication.views.app.signout.settings")
+ def test_empty_platform_domain_rejects_all_next(
+ self, mock_settings, mock_user_cls, mock_logout, mock_base_host, factory, view
+ ):
+ mock_settings.PLATFORM_DOMAIN = ""
+ mock_user_cls.objects.get.return_value = MagicMock()
+
+ response = view.get(
+ _make_get_request(factory, "next=https://docs.foss.arbisoft.com/x")
+ )
+
+ assert response.status_code == 400
+
+ @patch("plane.authentication.views.app.signout.base_host", return_value=_BASE_HOST)
+ @patch("plane.authentication.views.app.signout.logout")
+ @patch("plane.authentication.views.app.signout.User")
+ @patch("plane.authentication.views.app.signout.settings")
+ def test_no_next_falls_back_to_mpass_url(
+ self, mock_settings, mock_user_cls, mock_logout, mock_base_host, factory, view
+ ):
+ mock_settings.PLATFORM_DOMAIN = "foss.arbisoft.com"
+ mock_settings.MPASS_SIGNOUT_URL = _MPASS_URL
+ mock_user_cls.objects.get.return_value = MagicMock()
+
+ response = view.get(_make_get_request(factory))
+
+ mock_logout.assert_called_once()
+ assert response.status_code == 302
+ assert response["Location"] == _MPASS_URL
+
+ @patch("plane.authentication.views.app.signout.base_host", return_value=_BASE_HOST)
+ @patch("plane.authentication.views.app.signout.logout")
+ @patch("plane.authentication.views.app.signout.User")
+ @patch("plane.authentication.views.app.signout.settings")
+ def test_malformed_next_is_rejected(
+ self, mock_settings, mock_user_cls, mock_logout, mock_base_host, factory, view
+ ):
+ mock_settings.PLATFORM_DOMAIN = "foss.arbisoft.com"
+ mock_user_cls.objects.get.return_value = MagicMock()
+
+ response = view.get(_make_get_request(factory, "next=:::garbage"))
+
+ assert response.status_code == 400