diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj
index f9e79f915b6..a4f68c56c42 100644
--- a/backend/src/app/config.clj
+++ b/backend/src/app/config.clj
@@ -186,6 +186,7 @@
[:oidc-name-attr {:optional true} :string]
[:default-email-domain {:optional true} :string]
[:smb-default-workspace-name {:optional true} :string]
+ [:platform-domain {:optional true} :string]
[:ldap-attrs-email {:optional true} :string]
[:ldap-attrs-fullname {:optional true} :string]
diff --git a/backend/src/app/http.clj b/backend/src/app/http.clj
index 1578138d055..a9346e13f03 100644
--- a/backend/src/app/http.clj
+++ b/backend/src/app/http.clj
@@ -19,6 +19,7 @@
[app.http.errors :as errors]
[app.http.management :as mgmt]
[app.http.middleware :as mw]
+ [app.http.portal-logout :as-alias portal-logout]
[app.http.security :as sec]
[app.http.session :as session]
[app.http.websocket :as-alias ws]
@@ -153,6 +154,7 @@
[::mtx/routes schema:routes]
[::awsns/routes schema:routes]
[::mgmt/routes schema:routes]
+ [::portal-logout/routes schema:routes]
::session/manager
::setup/props
::db/pool])
@@ -187,4 +189,5 @@
(::ws/routes cfg)
(::oidc/routes cfg)
+ (::portal-logout/routes cfg)
(::rpc/routes cfg)]]))
diff --git a/backend/src/app/http/portal_logout.clj b/backend/src/app/http/portal_logout.clj
new file mode 100644
index 00000000000..52f7f716f2b
--- /dev/null
+++ b/backend/src/app/http/portal_logout.clj
@@ -0,0 +1,86 @@
+;; This Source Code Form is subject to the terms of the Mozilla Public
+;; License, v. 2.0. If a copy of the MPL was not distributed with this
+;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
+;;
+;; Copyright (c) KALEIDOS INC
+
+(ns app.http.portal-logout
+ "GET /api/auth/portal-logout — cross-origin redirect-chain entry point
+ for the foss-server-bundle portal's \"Log out of all apps\" flow.
+
+ Clears the auth-token cookie + invalidates the server-side session row
+ via the same `session/delete-fn` primitive that `auth/logout` RPC uses.
+ 302s to ?next= if its host equals PLATFORM_DOMAIN or is a subdomain;
+ otherwise returns 200 with cookies still cleared.
+
+ CSRF-exempt by design: cross-origin redirect chains cannot share
+ Penpot's CSRF token. Residual force-logout risk (`
`) is
+ acceptable — only the session itself is lost; the upstream Cognito
+ session controls real access via oauth2-proxy ForwardAuth, which
+ re-auths on the next request."
+ (:require
+ [app.config :as cf]
+ [app.http.session :as session]
+ [cuerdas.core :as str]
+ [integrant.core :as ig]
+ [yetti.response :as yres])
+ (:import
+ (java.net URI)))
+
+(set! *warn-on-reflection* true)
+
+(defn- with-session-id
+ "Bridge `::session/session.id` → `::session/id` so `session/delete-fn`
+ can drop the backing server-side row. Mirrors the helper in
+ app.http.auth-request used by the same primitive."
+ [request]
+ (if-let [sid (some-> request ::session/session :id)]
+ (assoc request ::session/id sid)
+ request))
+
+(defn- allowed-next?
+ "True iff `url` is a safe redirect target:
+ - scheme is http or https
+ - host equals PLATFORM_DOMAIN or is a subdomain
+ Suffix match enforces a dot boundary so `foss.arbisoft.com.evil` does
+ NOT match `foss.arbisoft.com`. Unset PLATFORM_DOMAIN → false (every
+ next= rejected)."
+ [url]
+ (let [platform-domain (some-> (cf/get :platform-domain) str/lower str/trim
+ (str/strip-prefix \".\"))]
+ (when (and platform-domain (seq platform-domain))
+ (try
+ (let [uri (URI. url)
+ scheme (some-> (.getScheme uri) str/lower)
+ host (some-> (.getHost uri) str/lower)]
+ (and host
+ (or (= scheme "http") (= scheme "https"))
+ (or (= host platform-domain)
+ (str/ends-with? host (str "." platform-domain)))))
+ (catch Throwable _ false)))))
+
+(defn- handler
+ [cfg request]
+ (let [delete-session! (session/delete-fn cfg)
+ next-url (some-> request :params :next str/trim)
+ base-response (if (and next-url (seq next-url) (allowed-next? next-url))
+ {::yres/status 302
+ ::yres/headers {"Location" next-url}}
+ {::yres/status 200
+ ::yres/body ""})]
+ ;; delete-fn attaches the Set-Cookie that expires auth-token, in
+ ;; addition to dropping the backing server-side row. Returns the
+ ;; response unchanged when no session is present (e.g. user already
+ ;; logged out by a previous step in the chain).
+ (delete-session! (with-session-id request) base-response)))
+
+(defmethod ig/assert-key ::routes
+ [_ params]
+ (assert (contains? params ::session/manager)
+ "portal-logout requires ::session/manager"))
+
+(defmethod ig/init-key ::routes
+ [_ cfg]
+ ["/api/auth/portal-logout"
+ {:handler (partial handler cfg)
+ :allowed-methods #{:get}}])
diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj
index 693752080a5..479a653b69a 100644
--- a/backend/src/app/main.clj
+++ b/backend/src/app/main.clj
@@ -21,6 +21,7 @@
[app.http.client :as-alias http.client]
[app.http.debug :as-alias http.debug]
[app.http.management :as mgmt]
+ [app.http.portal-logout :as-alias http.portal-logout]
[app.http.session :as session]
[app.http.session.tasks :as-alias session.tasks]
[app.http.websocket :as http.ws]
@@ -277,18 +278,22 @@
{::db/pool (ig/ref ::db/pool)
::setup/props (ig/ref ::setup/props)}
+ ::http.portal-logout/routes
+ {::session/manager (ig/ref ::session/manager)}
+
:app.http/router
- {::session/manager (ig/ref ::session/manager)
- ::db/pool (ig/ref ::db/pool)
- ::rpc/routes (ig/ref ::rpc/routes)
- ::setup/props (ig/ref ::setup/props)
- ::mtx/routes (ig/ref ::mtx/routes)
- ::oidc/routes (ig/ref ::oidc/routes)
- ::mgmt/routes (ig/ref ::mgmt/routes)
- ::http.debug/routes (ig/ref ::http.debug/routes)
- ::http.assets/routes (ig/ref ::http.assets/routes)
- ::http.ws/routes (ig/ref ::http.ws/routes)
- ::http.awsns/routes (ig/ref ::http.awsns/routes)}
+ {::session/manager (ig/ref ::session/manager)
+ ::db/pool (ig/ref ::db/pool)
+ ::rpc/routes (ig/ref ::rpc/routes)
+ ::setup/props (ig/ref ::setup/props)
+ ::mtx/routes (ig/ref ::mtx/routes)
+ ::oidc/routes (ig/ref ::oidc/routes)
+ ::mgmt/routes (ig/ref ::mgmt/routes)
+ ::http.portal-logout/routes (ig/ref ::http.portal-logout/routes)
+ ::http.debug/routes (ig/ref ::http.debug/routes)
+ ::http.assets/routes (ig/ref ::http.assets/routes)
+ ::http.ws/routes (ig/ref ::http.ws/routes)
+ ::http.awsns/routes (ig/ref ::http.awsns/routes)}
::http.debug/routes
{::db/pool (ig/ref ::db/pool)
diff --git a/backend/test/backend_tests/http_portal_logout_test.clj b/backend/test/backend_tests/http_portal_logout_test.clj
new file mode 100644
index 00000000000..724dfc08879
--- /dev/null
+++ b/backend/test/backend_tests/http_portal_logout_test.clj
@@ -0,0 +1,70 @@
+;; This Source Code Form is subject to the terms of the Mozilla Public
+;; License, v. 2.0. If a copy of the MPL was not distributed with this
+;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
+;;
+;; Copyright (c) KALEIDOS INC
+
+(ns backend-tests.http-portal-logout-test
+ "Unit tests for the ?next= allowlist in the portal-logout endpoint.
+
+ These cover the pure-function `allowed-next?` predicate. The handler
+ itself is exercised via integration in the FOSS bundle: with a real
+ session it 302s + clears the cookie, with a rejected next= it 200s +
+ clears the cookie. Verified manually against the devstack."
+ (:require
+ [app.config :as cf]
+ [app.http.portal-logout :as plg]
+ [clojure.test :as t]))
+
+(defmacro with-platform-domain
+ [domain & body]
+ `(with-redefs [cf/get (fn [k# & [default#]]
+ (if (= k# :platform-domain) ~domain default#))]
+ ~@body))
+
+(t/deftest allowed-next-host-equals-platform-domain
+ (with-platform-domain "foss.arbisoft.com"
+ (t/is (true? (#'plg/allowed-next? "https://foss.arbisoft.com/")))))
+
+(t/deftest allowed-next-host-is-subdomain
+ (with-platform-domain "foss.arbisoft.com"
+ (t/is (true? (#'plg/allowed-next? "https://pm.foss.arbisoft.com/done")))
+ (t/is (true? (#'plg/allowed-next? "https://docs.foss.arbisoft.com/x")))))
+
+(t/deftest allowed-next-rejects-other-host
+ (with-platform-domain "foss.arbisoft.com"
+ (t/is (false? (#'plg/allowed-next? "https://evil.example/steal")))))
+
+(t/deftest allowed-next-enforces-dot-boundary
+ ;; Suffix match without dot boundary would let foss.arbisoft.com.evil
+ ;; pass as a "subdomain" of foss.arbisoft.com. The endpoint must
+ ;; refuse this.
+ (with-platform-domain "foss.arbisoft.com"
+ (t/is (false? (#'plg/allowed-next? "https://foss.arbisoft.com.evil/x")))))
+
+(t/deftest allowed-next-rejects-non-http-scheme
+ ;; javascript:, data:, mailto: parse fine as URIs but must never be
+ ;; honoured as redirect targets.
+ (with-platform-domain "foss.arbisoft.com"
+ (t/is (false? (#'plg/allowed-next?
+ "javascript:alert(document.cookie)")))
+ (t/is (false? (#'plg/allowed-next?
+ "data:text/html,")))))
+
+(t/deftest allowed-next-rejects-everything-when-platform-domain-unset
+ (with-platform-domain nil
+ (t/is (false? (#'plg/allowed-next? "https://foss.arbisoft.com/"))))
+ (with-platform-domain ""
+ (t/is (false? (#'plg/allowed-next? "https://foss.arbisoft.com/")))))
+
+(t/deftest allowed-next-rejects-malformed-url
+ (with-platform-domain "foss.arbisoft.com"
+ (t/is (false? (#'plg/allowed-next? ":::garbage")))
+ (t/is (false? (#'plg/allowed-next? "not-a-url")))))
+
+(t/deftest allowed-next-normalises-platform-domain
+ ;; Operators sometimes write ".foss.arbisoft.com" (leading dot) — the
+ ;; predicate should treat that as the same domain.
+ (with-platform-domain ".foss.arbisoft.com"
+ (t/is (true? (#'plg/allowed-next? "https://pm.foss.arbisoft.com/")))
+ (t/is (false? (#'plg/allowed-next? "https://foss.arbisoft.com.evil/")))))