diff --git a/backend/scripts/_env b/backend/scripts/_env index b475bd2f2bb..5779aa01066 100644 --- a/backend/scripts/_env +++ b/backend/scripts/_env @@ -39,6 +39,8 @@ export PENPOT_FLAGS="\ enable-auto-file-snapshot \ enable-webhooks \ enable-access-tokens \ + enable-x-auth-request-headers \ + enable-x-auth-request-auto-register \ disable-tiered-file-data-storage \ enable-file-validation \ enable-file-schema-validation \ diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index 1fde3aa13c6..7150a932258 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -58,6 +58,9 @@ :objects-storage-fs-directory "assets" :auth-token-cookie-name "auth-token" + ;; Defaults match FOSS devstack SESSION_COOKIE_MAX_AGE_SECONDS / SESSION_COOKIE_REFRESH_SECONDS + :auth-token-cookie-max-age (ct/duration {:days 7}) + :auth-token-cookie-renewal-max-age (ct/duration {:hours 1}) :assets-path "/internal/assets/" :smtp-default-reply-to "Penpot " @@ -168,6 +171,7 @@ [:auth-token-cookie-name {:optional true} :string] [:auth-token-cookie-max-age {:optional true} ::ct/duration] + [:auth-token-cookie-renewal-max-age {:optional true} ::ct/duration] [:registration-domain-whitelist {:optional true} [::sm/set :string]] [:email-verify-threshold {:optional true} ::ct/duration] @@ -192,6 +196,8 @@ [:oidc-roles-attr {:optional true} :string] [:oidc-email-attr {:optional true} :string] [:oidc-name-attr {:optional true} :string] + [:default-email-domain {:optional true} :string] + [:smb-default-workspace-name {:optional true} :string] [:ldap-attrs-email {:optional true} :string] [:ldap-attrs-fullname {:optional true} :string] diff --git a/backend/src/app/http/auth_request.clj b/backend/src/app/http/auth_request.clj new file mode 100644 index 00000000000..867b9c3c4bb --- /dev/null +++ b/backend/src/app/http/auth_request.clj @@ -0,0 +1,243 @@ +;; 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.auth-request + "Middleware that trusts X-Auth-Request-* headers set by a forward-auth + proxy (e.g. oauth2-proxy, Authelia, Traefik ForwardAuth). + + Enabled via PENPOT_FLAGS: enable-x-auth-request-headers (parsed as :x-auth-request-headers). + Any request carrying an X-Auth-Request-Email header is treated as pre-authenticated. + A Penpot session cookie is created on the response so that the browser + does not need to visit the login screen. + + Optional: enable-x-auth-request-auto-register (parsed as :x-auth-request-auto-register) + automatically creates a Penpot profile (with a default team) for email addresses + that are not yet registered. After resolving a profile (new or existing), users + with no membership in any non-default team are joined to the shared team matching + PENPOT_SMB_DEFAULT_WORKSPACE_NAME (team.name); no fallback to another team." + + (:require + [app.common.logging :as l] + [app.config :as cf] + [app.db :as db] + [app.http :as-alias http] + [app.http.access-token :as-alias actoken] + [app.http.session :as session] + [app.rpc.commands.auth :as auth] + [app.rpc.commands.profile :as profile] + [cuerdas.core :as str] + [yetti.request :as yreq] + [yetti.response :as yres])) + +(set! *warn-on-reflection* true) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; HELPERS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- valid-email? + [s] + (boolean (re-matches #"[^\s@]+@[^\s@]+\.[^\s@]+" s))) + +(defn- resolve-email + "If the claim is already a valid email, return it as-is. + Otherwise treat it as a bare username and append @." + [email-claim] + (if (valid-email? email-claim) + email-claim + (let [domain (or (cf/get :default-email-domain) "askii.ai")] + (l/wrn :hint "x-auth-request: email claim is not a valid address, constructing from default-email-domain" + :claim email-claim + :domain domain) + (str (first (str/split email-claim #"@")) "@" domain)))) + +(defn- auto-join-team! + "_auto_join_workspace: ensure a ``team_profile_rel`` row for + the non-default team whose ``name`` matches PENPOT_SMB_DEFAULT_WORKSPACE_NAME + (:smb-default-workspace-name). Runs even when the profile already belongs to another + shared team (multi-team parity with Plane workspaces). + + If config is unset or no such team exists, does nothing — no fallback. Idempotent + INSERT ON CONFLICT DO NOTHING." + + [conn {:keys [id] :as _profile}] + (let [preferred (some-> (cf/get :smb-default-workspace-name) str/trim not-empty)] + (when-not (str/blank? preferred) + (when-let [team (db/exec-one! conn + ["SELECT id FROM team + WHERE is_default = false + AND deleted_at IS NULL + AND name = ? + LIMIT 1" + preferred])] + (db/insert! conn :team-profile-rel + {:team-id (:id team) + :profile-id id + :is-owner false + :is-admin false + :can-edit true} + {::db/on-conflict-do-nothing? true}) + (l/inf :hint "x-auth-request: ensured SMB shared team membership" + :profile-id (str id) + :team-id (str (:id team))))))) + +(defn- get-or-register-profile + "Looks up a profile by email. If not found and the + :x-auth-request-auto-register flag is enabled, creates a new active + profile with a default team. Returns nil when the profile does not + exist and auto-registration is disabled." + [cfg email fullname] + (db/tx-run! cfg + (fn [{:keys [::db/conn] :as cfg}] + (let [profile (or (profile/get-profile-by-email conn email) + (when (contains? cf/flags :x-auth-request-auto-register) + (let [display-name (or (not-empty fullname) + (first (str/split email #"@"))) + profile (auth/create-profile cfg + {:email email + :fullname display-name + :backend "x-auth-request" + :is-active true})] + (l/inf :hint "x-auth-request: auto-registered profile" + :email email + :profile-id (str (:id profile))) + (auth/create-profile-rels conn profile))))] + ;; Same semantics as Plane: join only provisioned SMB team by name — no fallback. + ;; Never fail auth if join fails (e.g. quotas, constraints). + (when profile + (try + (auto-join-team! conn profile) + (catch Throwable cause + (l/err :hint "x-auth-request: auto-join to shared team failed" + :profile-id (:id profile) + :cause cause)))) + profile)))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; MIDDLEWARE +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +;; Perf note: this middleware removes the previous fast-path that +;; short-circuited whenever wrap-session had already set +;; ::session/profile-id. With the fix in place, every authenticated +;; SSO request resolves the header email's profile (a transaction +;; with get-profile-by-email + an idempotent auto-join check). The +;; cost is intentional — correctness over throughput on the steady- +;; state path. If profiling shows this is hot, a follow-up can +;; reintroduce a fast-path by pre-loading the session profile's +;; email (so we can compare without get-or-register-profile) or by +;; caching email→profile-id in a short-lived in-memory map. + +(defn- wrap-authz + [handler cfg] + (fn [request] + (let [atoken-pid (::actoken/profile-id request) + session-pid (::session/profile-id request) + email-claim (yreq/get-header request "x-auth-request-email")] + (cond + ;; Access-token (API key) — programmatic identity issued out-of-band + ;; by the user. Not a browser SSO session, so the header is not + ;; meaningful here. Pass through unconditionally. + (some? atoken-pid) + (handler request) + + ;; No proxy header — trust whatever wrap-session decided. Without a + ;; header we have no upstream identity to compare against. + (str/blank? email-claim) + (handler request) + + :else + (let [local-part (first (str/split email-claim #"@")) + email (resolve-email email-claim) + fullname (or (not-empty (yreq/get-header request "x-auth-request-user")) + local-part) + profile (try + (get-or-register-profile cfg email fullname) + (catch Throwable cause + (l/err :hint "x-auth-request: error resolving profile" + :email email + :cause cause) + nil))] + (cond + (nil? profile) + ;; Header email doesn't resolve to a profile (and auto-register + ;; is off). No identity to switch *to* — pass through with + ;; whatever wrap-session set. + (do + (l/wrn :hint "x-auth-request: no profile found for email, passing through unauthenticated" + :email email) + (handler request)) + + (:is-blocked profile) + (do + (l/wrn :hint "x-auth-request: profile is blocked, denying access" + :email email + :profile-id (str (:id profile))) + {::yres/status 403}) + + (not (:is-active profile)) + (do + (l/wrn :hint "x-auth-request: profile is not active, denying access" + :email email + :profile-id (str (:id profile))) + {::yres/status 403}) + + ;; Steady state — existing browser session matches the proxy- + ;; asserted identity. No work to do. + (and session-pid (= session-pid (:id profile))) + (handler request) + + ;; Either no existing session, or the session points at a + ;; *different* profile than oauth2-proxy is asserting. Re-key. + ;; + ;; This is the fix for the session-sharing bug: portal "log out + ;; of all apps" clears the shared _oauth2_proxy cookie + Cognito + ;; session but NOT Penpot's auth-token cookie on its subdomain. + ;; Without this branch, wrap-session resolves the previous + ;; user's profile-id from the stale cookie and this middleware + ;; (under the previous always-skip-when-session rule) never + ;; overrode it. + :else + (do + (when session-pid + (l/inf :hint "x-auth-request: proxy identity differs from existing session — re-keying" + :session-profile-id (str session-pid) + :header-profile-id (str (:id profile)))) + (l/dbg :hint "x-auth-request: authenticating via forwarded header" + :email email + :profile-id (str (:id profile))) + (let [create-session! (session/create-fn cfg profile) + response (-> request + (assoc ::session/profile-id (:id profile)) + ;; Drop stale identity-carrying keys + ;; so downstream code does not see the + ;; previous user's data after re-key. + ;; + ;; ::http/auth-data — errors.clj logs + ;; auth-data.claims.uid as + ;; :request/profile-id; rpc/helpers + ;; exposes the map to RPC handlers via + ;; get-auth-data. + ;; + ;; ::session/session — read indirectly + ;; by session/get-session, which is + ;; called in update-profile-password's + ;; invalidate-others path. Leaving + ;; alice's session here means a + ;; password-change RPC made on the + ;; re-keyed request would invalidate + ;; alice's sessions instead of bob's. + (dissoc ::http/auth-data ::session/session) + handler)] + ;; Fresh auth-token cookie; replaces the stale one the + ;; browser still has (if any). + (create-session! request response))))))))) + +(def authz + {:name ::authz + :compile (fn [& _] + (when (contains? cf/flags :x-auth-request-headers) + wrap-authz))}) diff --git a/backend/src/app/http/session.clj b/backend/src/app/http/session.clj index f9154ab1359..3c26c372dff 100644 --- a/backend/src/app/http/session.clj +++ b/backend/src/app/http/session.clj @@ -218,10 +218,11 @@ (defn- renew-session? [{:keys [id modified-at] :as session}] - (or (string? id) - (and (ct/inst? modified-at) - (let [elapsed (ct/diff modified-at (ct/now))] - (neg? (compare default-renewal-max-age elapsed)))))) + (let [renewal-max (cf/get :auth-token-cookie-renewal-max-age default-renewal-max-age)] + (or (string? id) + (and (ct/inst? modified-at) + (let [elapsed (ct/diff modified-at (ct/now))] + (neg? (compare renewal-max elapsed))))))) (defn- wrap-authz [handler {:keys [::manager] :as cfg}] @@ -247,12 +248,32 @@ (binding [ct/*clock* (clock/get-clock (:profile-id session))] (handler request))] - (if (and session (renew-session? session)) - (let [session (->> session - (update-session manager) - (assign-token cfg))] - (assign-session-cookie response session)) - response)) + ;; Renewal runs after the inner handler. Two cases where it + ;; MUST step aside: + ;; + ;; 1. The response already carries the auth-token cookie — + ;; e.g. wrap-authz re-keyed the session to a new user. + ;; Renewing alice's cookie on top of bob's freshly-issued + ;; one would silently undo the re-key. + ;; + ;; 2. The response is an error (status >= 400) — e.g. proxy + ;; identity mismatch with a blocked/inactive incoming + ;; profile produced a 403. Renewing alice's cookie on the + ;; denial response would EXTEND her session lifetime even + ;; though the upstream identity has changed. Better to let + ;; her cookie age out naturally (or get cleared on the next + ;; successful flow) than to refresh it on a mismatch. + (let [status (::yres/status response)] + (if (and session + (renew-session? session) + (or (nil? status) (< status 400)) + (not (contains? (::yres/cookies response) + (cf/get :auth-token-cookie-name)))) + (let [session (->> session + (update-session manager) + (assign-token cfg))] + (assign-session-cookie response session)) + response))) (= type :bearer) (let [session (case (:ver metadata) @@ -279,7 +300,7 @@ [response {token :token modified-at :modified-at}] (let [max-age (cf/get :auth-token-cookie-max-age default-cookie-max-age) created-at modified-at - renewal (ct/plus created-at default-renewal-max-age) + renewal (ct/plus created-at (cf/get :auth-token-cookie-renewal-max-age default-renewal-max-age)) expires (ct/plus created-at max-age) secure? (contains? cf/flags :secure-session-cookies) strict? (contains? cf/flags :strict-session-cookies) diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index 15174f1a152..6c3d51039dc 100644 --- a/backend/src/app/rpc.clj +++ b/backend/src/app/rpc.clj @@ -19,6 +19,7 @@ [app.db :as db] [app.http :as-alias http] [app.http.access-token :as actoken] + [app.http.auth-request :as auth-request] [app.http.client :as-alias http.client] [app.http.middleware :as mw] [app.http.security :as sec] @@ -378,7 +379,8 @@ {:middleware [[mw/cors] [sec/client-header-check] [session/authz cfg] - [actoken/authz cfg]] + [actoken/authz cfg] + [auth-request/authz cfg]] :handler (make-rpc-handler methods)}] (doc/routes :methods methods @@ -396,5 +398,6 @@ {:middleware [[mw/cors] [sec/client-header-check] [session/authz cfg] - [actoken/authz cfg]] + [actoken/authz cfg] + [auth-request/authz cfg]] :handler (make-rpc-handler methods)}]])) diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index a9e766bd9b0..2f9e367188d 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -316,9 +316,16 @@ (sto/put-object! storage params))) ;; --- MUTATION: Request Email Change - -(declare ^:private request-email-change!) -(declare ^:private change-email-immediately!) +;; +;; Disabled in this Penpot fork. The deployment runs exclusively behind +;; oauth2-proxy / Cognito (SSO); the user's email is the identity asserted by +;; the upstream IdP via X-Auth-Request-Email and is also the lookup key in +;; auth_request.clj. A local email change would diverge from the IdP value +;; and either lock the user out of their own workspace or pre-stage a +;; profile that hijacks the next victim's first sign-in. +;; +;; The complementary refusal lives in rpc/commands/verify_token.clj +;; (process-token :change-email) so any pre-existing token cannot be redeemed. (def ^:private schema:request-email-change @@ -328,81 +335,10 @@ (sv/defmethod ::request-email-change {::doc/added "1.0" ::sm/params schema:request-email-change} - [cfg {:keys [::rpc/profile-id email] :as params}] - (db/tx-run! cfg - (fn [cfg] - (let [profile (db/get-by-id cfg :profile profile-id) - params (assoc params - :profile profile - :email (clean-email email))] - (if (contains? cf/flags :smtp) - (request-email-change! cfg params) - (change-email-immediately! cfg params)))))) - -(defn- change-email-immediately! - [{:keys [::db/conn]} {:keys [profile email] :as params}] - (when (not= email (:email profile)) - (check-profile-existence! conn params)) - - (db/update! conn :profile - {:email email} - {:id (:id profile)}) - - {:changed true}) - -(defn- request-email-change! - [{:keys [::db/conn] :as cfg} {:keys [profile email] :as params}] - (let [token (tokens/generate cfg - {:iss :change-email - :exp (ct/in-future "15m") - :profile-id (:id profile) - :email email}) - ptoken (tokens/generate cfg - {:iss :profile-identity - :profile-id (:id profile) - :exp (ct/in-future {:days 30})})] - - (when (not= email (:email profile)) - (check-profile-existence! conn params)) - - (when-not (eml/allow-send-emails? conn profile) - (ex/raise :type :validation - :code :profile-is-muted - :hint "looks like the profile has reported repeatedly as spam or has permanent bounces.")) - - (when (eml/has-bounce-reports? conn email) - (ex/raise :type :restriction - :code :email-has-permanent-bounces - :email email - :hint "looks like the email has bounce reports")) - - (when (eml/has-complaint-reports? conn email) - (ex/raise :type :restriction - :code :email-has-complaints - :email email - :hint "looks like the email has spam complaint reports")) - - (when (eml/has-bounce-reports? conn (:email profile)) - (ex/raise :type :restriction - :code :email-has-permanent-bounces - :email (:email profile) - :hint "looks like the email has bounce reports")) - - (when (eml/has-complaint-reports? conn (:email profile)) - (ex/raise :type :restriction - :code :email-has-complaints - :email (:email profile) - :hint "looks like the email has spam complaint reports")) - - (eml/send! {::eml/conn conn - ::eml/factory eml/change-email - :public-uri (cf/get :public-uri) - :to (:email profile) - :name (:fullname profile) - :pending-email email - :token token - :extra-data ptoken}) - nil)) + [_cfg _params] + (ex/raise :type :restriction + :code :email-managed-by-external-idp + :hint "email is managed by the upstream identity provider and cannot be changed in Penpot")) ;; --- MUTATION: Update Profile Props diff --git a/backend/src/app/rpc/commands/verify_token.clj b/backend/src/app/rpc/commands/verify_token.clj index 246c35fe11b..e83d929233e 100644 --- a/backend/src/app/rpc/commands/verify_token.clj +++ b/backend/src/app/rpc/commands/verify_token.clj @@ -42,20 +42,15 @@ (db/tx-run! cfg process-token params claims))) (defmethod process-token :change-email - [{:keys [::db/conn] :as cfg} _params {:keys [profile-id email] :as claims}] - (let [email (profile/clean-email email)] - (when (profile/get-profile-by-email conn email) - (ex/raise :type :validation - :code :email-already-exists)) - - (db/update! conn :profile - {:email email} - {:id profile-id}) - - (rph/with-meta claims - {::audit/name "update-profile-email" - ::audit/props {:email email} - ::audit/profile-id profile-id}))) + [_cfg _params _claims] + ;; Belt-and-suspenders for the unconditional disable in + ;; rpc/commands/profile.clj. Even if a valid :change-email token already + ;; exists (minted via a previous deploy or a fork), the redeem path must + ;; refuse it — otherwise the same divergence vector is reachable here. + ;; The email is owned by the upstream IdP (Cognito via oauth2-proxy). + (ex/raise :type :restriction + :code :email-managed-by-external-idp + :hint "email is managed by the upstream identity provider and cannot be changed in Penpot")) (defmethod process-token :verify-email [{:keys [::db/conn] :as cfg} _ {:keys [profile-id] :as claims}] diff --git a/backend/test/backend_tests/config_session_cookie_test.clj b/backend/test/backend_tests/config_session_cookie_test.clj new file mode 100644 index 00000000000..8e047dea69a --- /dev/null +++ b/backend/test/backend_tests/config_session_cookie_test.clj @@ -0,0 +1,50 @@ +;; 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.config-session-cookie-test + "Auth-token cookie max-age / renewal-max-age: defaults, env override (merge), session renewal threshold." + (:require + [app.common.time :as ct] + [app.config :as cf] + [app.http.session :as session] + [clojure.test :as t] + [environ.core :refer [env]])) + +(t/deftest default-map-includes-unified-session-durations + (let [max-age (:auth-token-cookie-max-age cf/default) + renewal (:auth-token-cookie-renewal-max-age cf/default)] + (t/is (ct/duration? max-age)) + (t/is (ct/duration? renewal)) + (t/is (= (ct/duration {:days 7}) max-age)) + (t/is (= (ct/duration {:hours 1}) renewal)))) + +(t/deftest read-config-uses-defaults-when-env-prefix-has-no-keys + ;; No process env uses this prefix; read-env is empty → merged config is `default` only. + (let [cfg (cf/read-config :prefix "penpotzzzzunused" + :default cf/default)] + (t/is (= (ct/duration {:days 7}) (:auth-token-cookie-max-age cfg))) + (t/is (= (ct/duration {:hours 1}) (:auth-token-cookie-renewal-max-age cfg))))) + +(t/deftest read-config-env-overrides-default + (let [extra {:penpot-auth-token-cookie-max-age "172800s" + :penpot-auth-token-cookie-renewal-max-age "7200s"} + merged (merge env extra)] + (with-redefs [env merged] + (let [cfg (cf/read-config :default cf/default)] + (t/is (= (ct/duration {:days 2}) (:auth-token-cookie-max-age cfg))) + (t/is (= (ct/duration {:hours 2}) (:auth-token-cookie-renewal-max-age cfg))))))) + +(t/deftest renew-session-respects-configured-renewal-max-age + (binding [cf/config (assoc cf/config + :auth-token-cookie-renewal-max-age (ct/duration {:minutes 5}))] + (let [now (ct/now) + recent (ct/minus now (ct/duration {:minutes 1})) + stale (ct/minus now (ct/duration {:minutes 10}))] + (t/is (not (#'session/renew-session? {:id (random-uuid) :modified-at recent}))) + (t/is (#'session/renew-session? {:id (random-uuid) :modified-at stale}))))) + +(t/deftest renew-session-true-for-legacy-string-session-id + (t/is (#'session/renew-session? {:id "legacy-string-session" :modified-at (ct/now)}))) diff --git a/backend/test/backend_tests/helpers.clj b/backend/test/backend_tests/helpers.clj index 5e8fc40c60c..73d22ce7230 100644 --- a/backend/test/backend_tests/helpers.clj +++ b/backend/test/backend_tests/helpers.clj @@ -64,7 +64,8 @@ {:database-uri "postgresql://postgres/penpot_test" :redis-uri "redis://redis/1" :auto-file-snapshot-every 1 - :file-data-backend "db"}) + :file-data-backend "db" + :default-email-domain "askii.ai"}) (def config (cf/read-config :prefix "penpot-test" diff --git a/backend/test/backend_tests/http_middleware_test.clj b/backend/test/backend_tests/http_middleware_test.clj index 809e43f9e3f..9b498c7437f 100644 --- a/backend/test/backend_tests/http_middleware_test.clj +++ b/backend/test/backend_tests/http_middleware_test.clj @@ -7,14 +7,17 @@ (ns backend-tests.http-middleware-test (:require [app.common.time :as ct] + [app.config :as cf] [app.db :as db] [app.http :as-alias http] - [app.http.access-token] + [app.http.access-token :as access-token] + [app.http.auth-request] [app.http.middleware :as mw] [app.http.session :as session] [app.main :as-alias main] [app.rpc :as-alias rpc] [app.rpc.commands.access-token] + [app.rpc.commands.profile :as profile] [app.tokens :as tokens] [backend-tests.helpers :as th] [clojure.test :as t] @@ -136,3 +139,291 @@ (t/is (= "penpot" (:aud claims))) (t/is (= (:id session) (:sid claims))) (t/is (= (:id profile) (:uid claims))))) + +(t/deftest session-authz-does-not-renew-on-error-response + ;; The renewal guard MUST also step aside on error responses. + ;; Without this, a 403 produced by wrap-authz (proxy identity + ;; mismatch with a blocked/inactive incoming profile) would still + ;; cause alice's session cookie to be renewed on the way out — + ;; extending her session lifetime on the very denial that signaled + ;; her identity is no longer valid upstream. + (let [cfg th/*system* + manager (session/inmemory-manager) + profile (th/create-profile* 91) + t0 (ct/inst "2025-01-01T00:00:00Z") + t1 (ct/plus t0 (ct/duration {:seconds 2})) + threshold (ct/duration {:seconds 1}) + handler (-> (fn [_req] {::yres/status 403}) + (#'session/wrap-authz {::session/manager manager}) + (#'mw/wrap-auth {:bearer (partial session/decode-token cfg) + :cookie (partial session/decode-token cfg)})) + token (binding [ct/*clock* (ct/fixed-clock t0)] + (->> (session/create-session manager {:profile-id (:id profile) + :user-agent "user agent"}) + (#'session/assign-token cfg))) + response (binding [cf/config (assoc cf/config :auth-token-cookie-renewal-max-age threshold) + ct/*clock* (ct/fixed-clock t1)] + (handler (->DummyRequest {} {"auth-token" (:token token)})))] + (t/is (= 403 (::yres/status response))) + (t/is (not (contains? (::yres/cookies response) "auth-token"))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; X-Auth-Request middleware tests +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- make-xauth-cfg + [] + (assoc th/*system* ::session/manager (session/inmemory-manager))) + +(t/deftest x-auth-request-no-email-header + (let [captured (volatile! nil) + handler (#'app.http.auth-request/wrap-authz + (fn [req] (vreset! captured req) {::yres/status 200}) + (make-xauth-cfg))] + (handler (->DummyRequest {} {})) + (t/is (nil? (::session/profile-id @captured))))) + +(t/deftest x-auth-request-preserves-session-when-header-email-unresolvable + ;; When wrap-session has resolved alice's profile-id from the local + ;; auth-token cookie AND the proxy header email cannot be resolved to + ;; a profile (unknown user + auto-register off), the existing session + ;; passes through unchanged. The middleware only re-keys when it has + ;; a *real* alternative identity to switch to. + (let [profile-id (random-uuid) + handler (#'app.http.auth-request/wrap-authz + (fn [req] req) + (make-xauth-cfg)) + request (-> (->DummyRequest {"x-auth-request-email" "user@example.com"} {}) + (assoc ::session/profile-id profile-id)) + result (handler request)] + (t/is (= profile-id (::session/profile-id result))))) + +(t/deftest x-auth-request-rekeys-when-session-identity-differs + ;; Repro of the QA-reported session-sharing bug: alice's auth-token + ;; cookie persists on Penpot's subdomain after the portal "log out of + ;; all apps"; bob then logs in upstream. wrap-session resolves alice's + ;; profile-id from the old cookie, but oauth2-proxy is forwarding + ;; bob's email. The middleware MUST re-key to bob. + (let [alice (th/create-profile* 1 {:is-active true}) + bob (th/create-profile* 2 {:is-active true}) + captured (volatile! nil) + cfg (make-xauth-cfg) + handler (#'app.http.auth-request/wrap-authz + (fn [req] (vreset! captured req) {::yres/status 200}) + cfg) + request (-> (->DummyRequest {"x-auth-request-email" (:email bob)} {}) + (assoc ::session/profile-id (:id alice))) + response (handler request)] + ;; Downstream handler sees bob's profile-id, not alice's. + (t/is (= (:id bob) (::session/profile-id @captured))) + ;; A fresh auth-token cookie is issued for bob's session. + (t/is (contains? (::yres/cookies response) "auth-token")))) + +(t/deftest x-auth-request-no-rekey-when-session-matches-header + ;; Steady-state guard: the browser session matches the proxy identity. + ;; No re-key, no new cookie. Without this, every authenticated request + ;; would mint a fresh cookie. + (let [profile (th/create-profile* 1 {:is-active true}) + captured (volatile! nil) + cfg (make-xauth-cfg) + handler (#'app.http.auth-request/wrap-authz + (fn [req] (vreset! captured req) {::yres/status 200}) + cfg) + request (-> (->DummyRequest {"x-auth-request-email" (:email profile)} {}) + (assoc ::session/profile-id (:id profile))) + response (handler request)] + (t/is (= (:id profile) (::session/profile-id @captured))) + (t/is (not (contains? (::yres/cookies response) "auth-token"))))) + +(t/deftest x-auth-request-rekey-clears-stale-auth-data + ;; The re-keyed request must have ::http/auth-data removed before the + ;; inner handler runs. errors.clj logs auth-data.claims.uid as + ;; :request/profile-id and rpc/helpers exposes the map to RPC + ;; handlers via get-auth-data — both would otherwise see alice's UID + ;; after we re-keyed to bob, leaking the stale identity across the + ;; boundary the middleware thinks it has crossed. + (let [alice (th/create-profile* 1 {:is-active true}) + bob (th/create-profile* 2 {:is-active true}) + captured (volatile! nil) + cfg (make-xauth-cfg) + handler (#'app.http.auth-request/wrap-authz + (fn [req] (vreset! captured req) {::yres/status 200}) + cfg) + request (-> (->DummyRequest {"x-auth-request-email" (:email bob)} {}) + (assoc ::session/profile-id (:id alice)) + (assoc ::http/auth-data {:type :cookie + :claims {:uid (:id alice) + :sid (random-uuid)}}))] + (handler request) + (t/is (= (:id bob) (::session/profile-id @captured))) + (t/is (nil? (::http/auth-data @captured))))) + +(t/deftest x-auth-request-rekey-clears-stale-session-map + ;; ::session/session is read indirectly by session/get-session, which + ;; the update-profile-password RPC calls via invalidate-others. If + ;; alice's stale session map survives the re-key to bob, a password- + ;; change made on this request would invalidate alice's other + ;; sessions instead of bob's. Surfaced by Copilot review on PR #22. + (let [alice (th/create-profile* 1 {:is-active true}) + bob (th/create-profile* 2 {:is-active true}) + alice-session {:id (random-uuid) + :profile-id (:id alice) + :user-agent "alice's ua"} + captured (volatile! nil) + cfg (make-xauth-cfg) + handler (#'app.http.auth-request/wrap-authz + (fn [req] (vreset! captured req) {::yres/status 200}) + cfg) + request (-> (->DummyRequest {"x-auth-request-email" (:email bob)} {}) + (assoc ::session/profile-id (:id alice)) + (assoc ::session/session alice-session))] + (handler request) + (t/is (= (:id bob) (::session/profile-id @captured))) + (t/is (nil? (::session/session @captured))))) + +(t/deftest session-authz-renewal-does-not-overwrite-rekeyed-cookie + ;; Integration guard: session/wrap-authz runs AFTER wrap-authz on the + ;; way out and would normally renew the incoming session cookie. If + ;; wrap-authz has already issued a fresh auth-token cookie for the + ;; re-keyed identity (bob), the renewal MUST step aside — otherwise + ;; alice's renewed cookie clobbers bob's fresh one and the re-key is + ;; silently undone. + ;; + ;; This locks in the (not (contains? response cookies "auth-token")) + ;; guard added to session.clj. A future refactor that removes it + ;; fails this test. + (let [alice (th/create-profile* 91 {:is-active true}) + bob (th/create-profile* 92 {:is-active true}) + cfg (make-xauth-cfg) + t0 (ct/inst "2025-01-01T00:00:00Z") + t1 (ct/plus t0 (ct/duration {:seconds 2})) + ;; Lower than (t1 - t0) so the seeded cookie is always due for renewal. + renewal-threshold (ct/duration {:seconds 1}) + middleware (-> (fn [req] {::yres/status 200 + :seen-profile-id (::session/profile-id req)}) + (#'app.http.auth-request/wrap-authz cfg) + (#'session/wrap-authz cfg) + (#'mw/wrap-auth {:bearer (partial session/decode-token cfg) + :cookie (partial session/decode-token cfg)})) + seeded-token (binding [ct/*clock* (ct/fixed-clock t0)] + (get-in ((session/create-fn cfg alice) + (->DummyRequest {} {}) + {::yres/status 200}) + [::yres/cookies "auth-token" :value])) + response (binding [cf/config (assoc cf/config + :auth-token-cookie-renewal-max-age + renewal-threshold) + ct/*clock* (ct/fixed-clock t1)] + (middleware (->DummyRequest {"x-auth-request-email" (:email bob)} + {"auth-token" seeded-token}))) + rekeyed-token (get-in response [::yres/cookies "auth-token" :value]) + followup (middleware (->DummyRequest {} {"auth-token" rekeyed-token}))] + ;; Cookie was re-keyed (not just renewed): the new token differs. + (t/is (some? rekeyed-token)) + (t/is (not= seeded-token rekeyed-token)) + ;; This request's inner handler ran as bob, not alice. + (t/is (= (:id bob) (:seen-profile-id response))) + ;; The re-keyed token, decoded on a follow-up request, still resolves to bob. + ;; If renewal had clobbered the response cookie with a renewed alice token, + ;; this would resolve to alice instead. + (t/is (= (:id bob) (:seen-profile-id followup))))) + +(t/deftest x-auth-request-skips-when-access-token-present + (let [profile-id (random-uuid) + handler (#'app.http.auth-request/wrap-authz + (fn [req] req) + (make-xauth-cfg)) + request (-> (->DummyRequest {"x-auth-request-email" "user@example.com"} {}) + (assoc ::access-token/profile-id profile-id)) + result (handler request)] + (t/is (= profile-id (::access-token/profile-id result))))) + +(t/deftest x-auth-request-authenticates-existing-active-profile + (let [profile (th/create-profile* 1 {:is-active true}) + captured (volatile! nil) + cfg (make-xauth-cfg) + handler (#'app.http.auth-request/wrap-authz + (fn [req] (vreset! captured req) {::yres/status 200}) + cfg) + response (handler (->DummyRequest {"x-auth-request-email" (:email profile)} {}))] + ;; The profile-id must be injected into the request seen by the downstream handler + (t/is (= (:id profile) (::session/profile-id @captured))) + ;; A session cookie must be set on the response + (t/is (contains? (::yres/cookies response) "auth-token")))) + +(t/deftest x-auth-request-blocked-profile-returns-403 + (let [profile (th/create-profile* 2 {:is-active true}) + _ (th/db-update! :profile {:is-blocked true} {:id (:id profile)}) + handler (#'app.http.auth-request/wrap-authz + (fn [_] {::yres/status 200}) + (make-xauth-cfg)) + response (handler (->DummyRequest {"x-auth-request-email" (:email profile)} {}))] + (t/is (= 403 (::yres/status response))))) + +(t/deftest x-auth-request-inactive-profile-returns-403 + (let [profile (th/create-profile* 3 {:is-active false}) + handler (#'app.http.auth-request/wrap-authz + (fn [_] {::yres/status 200}) + (make-xauth-cfg)) + response (handler (->DummyRequest {"x-auth-request-email" (:email profile)} {}))] + (t/is (= 403 (::yres/status response))))) + +(t/deftest x-auth-request-unknown-email-no-autoregister + (let [captured (volatile! nil) + handler (#'app.http.auth-request/wrap-authz + (fn [req] (vreset! captured req) {::yres/status 200}) + (make-xauth-cfg))] + (handler (->DummyRequest {"x-auth-request-email" "nobody@example.com"} {})) + (t/is (nil? (::session/profile-id @captured))))) + +(t/deftest x-auth-request-auto-register-creates-active-profile + (binding [cf/flags (conj cf/flags :x-auth-request-auto-register)] + (let [email "newuser@example.com" + fullname "New User" + captured (volatile! nil) + cfg (make-xauth-cfg) + handler (#'app.http.auth-request/wrap-authz + (fn [req] (vreset! captured req) {::yres/status 200}) + cfg) + response (handler (->DummyRequest {"x-auth-request-email" email + "x-auth-request-user" fullname} {}))] + ;; Profile must be injected into the downstream request + (t/is (uuid? (::session/profile-id @captured))) + ;; A session cookie must be set so the browser is authenticated + (t/is (contains? (::yres/cookies response) "auth-token")) + ;; The created profile must be active and match the forwarded email + (let [profile (db/tx-run! cfg + (fn [{:keys [::db/conn]}] + (profile/get-profile-by-email conn email)))] + (t/is (some? profile)) + (t/is (true? (:is-active profile))) + (t/is (= (::session/profile-id @captured) (:id profile))))))) + +(t/deftest x-auth-request-auto-register-joins-named-smb-team + (let [orig-get cf/get] + (binding [cf/flags (conj cf/flags :x-auth-request-auto-register) + cf/get (fn + ([k] (if (= k :smb-default-workspace-name) + "team1" + (orig-get k))) + ([k d] (if (= k :smb-default-workspace-name) + "team1" + (orig-get k d))))] + (let [owner (th/create-profile* 1 {:is-active true}) + ;; Matches :smb-default-workspace-name "team1" from create-team* default name. + _ (th/create-team* 1 {:profile-id (:id owner)}) + email "xauth-autojoin@example.com" + captured (volatile! nil) + cfg (make-xauth-cfg) + handler (#'app.http.auth-request/wrap-authz + (fn [req] (vreset! captured req) {::yres/status 200}) + cfg) + _ (handler (->DummyRequest {"x-auth-request-email" email + "x-auth-request-user" "Shared Team Join"} {})) + profile (db/tx-run! cfg + (fn [{:keys [::db/conn]}] + (profile/get-profile-by-email conn email))) + rels (db/query th/*pool* :team-profile-rel {:profile-id (:id profile)})] + (t/is (uuid? (:id profile))) + (t/is (some #(not= (:team-id %) (:default-team-id profile)) rels) + "profile should have a membership on the provisioned SMB (shared) team"))))) diff --git a/backend/test/backend_tests/rpc_profile_test.clj b/backend/test/backend_tests/rpc_profile_test.clj index 22f0e966f21..0072de6832b 100644 --- a/backend/test/backend_tests/rpc_profile_test.clj +++ b/backend/test/backend_tests/rpc_profile_test.clj @@ -931,60 +931,50 @@ (let [reloaded (th/db-get :profile {:id (:id profile)})] (t/is (true? (:is-active reloaded)))))) -(t/deftest email-change-request +(t/deftest email-change-request-is-disabled + ;; This Penpot fork runs exclusively behind oauth2-proxy / Cognito. The + ;; RPC is unconditionally refused — the email is owned by the upstream + ;; IdP and a local change would either lock the user out or pre-stage a + ;; profile takeover (see rpc/commands/profile.clj for the full rationale). + ;; Asserts the rejection AND that the profile row is not mutated, so a + ;; future regression that performs a partial DB write is caught. (with-mocks [mock {:target 'app.email/send! :return nil}] (let [profile (th/create-profile* 1) - pool (:app.db/pool th/*system*) data {::th/type :request-email-change ::rpc/profile-id (:id profile) - :email "user1@example.com"}] + :email "attacker-target@example.com"} + out (th/command! data)] - ;; without complaints - (let [out (th/command! data)] - ;; (th/print-result! out) - (t/is (nil? (:result out))) - (let [mock @mock] - (t/is (= 1 (:call-count mock))) - (t/is (true? (:called? mock))))) - - ;; with complaints - (th/create-global-complaint-for pool {:type :complaint :email (:email data)}) - (let [out (th/command! data)] - ;; (th/print-result! out) - (t/is (nil? (:result out))) - - (let [edata (-> out :error ex-data)] - (t/is (= :restriction (:type edata))) - (t/is (= :email-has-complaints (:code edata)))) - - (t/is (= 1 (:call-count @mock)))) - - ;; with bounces - (th/create-global-complaint-for pool {:type :bounce :email (:email data)}) - (let [out (th/command! data)] - ;; (th/print-result! out) - - (let [edata (-> out :error ex-data)] - (t/is (= :restriction (:type edata))) - (t/is (= :email-has-permanent-bounces (:code edata)))) - - (t/is (= 1 (:call-count @mock))))))) + (t/is (not (th/success? out))) + (let [edata (-> out :error ex-data)] + (t/is (= :restriction (:type edata))) + (t/is (= :email-managed-by-external-idp (:code edata)))) + (t/is (false? (:called? @mock))) + (let [pool (:app.db/pool th/*system*) + row (db/get-by-id pool :profile (:id profile))] + (t/is (= (:email profile) (:email row))))))) -(t/deftest email-change-request-without-smtp - (with-mocks [mock {:target 'app.email/send! :return nil}] - (with-redefs [app.config/flags #{}] - (let [profile (th/create-profile* 1) - pool (:app.db/pool th/*system*) - data {::th/type :request-email-change - ::rpc/profile-id (:id profile) - :email "user1@example.com"} - out (th/command! data)] +(t/deftest email-change-token-is-rejected + ;; Belt-and-suspenders for the request-side disable: a valid :change-email + ;; token (e.g. minted by a previous deploy or fork) must not redeem here + ;; either, or the same takeover vector is reachable via verify-token. + (let [profile (th/create-profile* 1) + token (tokens/generate th/*system* + {:iss :change-email + :exp (ct/in-future "15m") + :profile-id (:id profile) + :email "attacker-target@example.com"}) + data {::th/type :verify-token :token token} + out (th/command! data)] - ;; (th/print-result! out) - (t/is (false? (:called? @mock))) - (let [res (:result out)] - (t/is (= {:changed true} res))))))) + (t/is (not (th/success? out))) + (let [edata (-> out :error ex-data)] + (t/is (= :restriction (:type edata))) + (t/is (= :email-managed-by-external-idp (:code edata)))) + (let [pool (:app.db/pool th/*system*) + row (db/get-by-id pool :profile (:id profile))] + (t/is (= (:email profile) (:email row)))))) (t/deftest request-profile-recovery diff --git a/docker/images/files/config.js b/docker/images/files/config.js index 7bc9ce94046..8485f2fb9e6 100644 --- a/docker/images/files/config.js +++ b/docker/images/files/config.js @@ -1,2 +1,4 @@ // Frontend configuration //var penpotFlags = ""; +//var penpotOIDCName = ""; +//var penpotMpassSignoutUrl = ""; diff --git a/docker/images/files/nginx-entrypoint.sh b/docker/images/files/nginx-entrypoint.sh index e791ac64f00..2cff8839208 100644 --- a/docker/images/files/nginx-entrypoint.sh +++ b/docker/images/files/nginx-entrypoint.sh @@ -25,7 +25,30 @@ update_flags() { fi } +update_oidc_name() { + if [ -n "$PENPOT_OIDC_NAME" ]; then + echo "$(sed \ + -e "s|^//var penpotOIDCName = .*;|var penpotOIDCName = \"$PENPOT_OIDC_NAME\";|g" \ + "$1")" > "$1" + fi +} + +update_mpass_signout_url() { + # Injected by foss-server-bundle-devstack for mPass SSO full-3-layer + # logout. When MPASS_SIGNOUT_URL is set, the frontend logout button + # redirects there instead of /auth/login — clearing the oauth2-proxy + # cookie and the Cognito session in addition to the penpot session. + if [ -n "$MPASS_SIGNOUT_URL" ]; then + # `|` as sed delimiter because the URL contains `/` and `&`. + echo "$(sed \ + -e "s|^//var penpotMpassSignoutUrl = .*;|var penpotMpassSignoutUrl = \"$MPASS_SIGNOUT_URL\";|g" \ + "$1")" > "$1" + fi +} + update_flags /var/www/app/js/config.js +update_oidc_name /var/www/app/js/config.js +update_mpass_signout_url /var/www/app/js/config.js ######################################### ## Nginx Config diff --git a/frontend/src/app/config.cljs b/frontend/src/app/config.cljs index 703f888f918..ae9f153d386 100644 --- a/frontend/src/app/config.cljs +++ b/frontend/src/app/config.cljs @@ -154,6 +154,13 @@ (def terms-of-service-uri (obj/get global "penpotTermsOfServiceURI")) (def privacy-policy-uri (obj/get global "penpotPrivacyPolicyURI")) + +;; mPass SSO full-3-layer signout URL. Read at runtime from config.js +;; (injected via nginx-entrypoint.sh from the MPASS_SIGNOUT_URL env var). +;; When set, the logout event redirects here instead of to the native +;; penpot /auth/login screen so the oauth2-proxy cookie and Cognito +;; session are also cleared. Nil on non-SSO deployments. +(def mpass-signout-url (obj/get global "penpotMpassSignoutUrl")) (def flex-help-uri (obj/get global "penpotGridHelpURI" "https://help.penpot.app/user-guide/flexible-layouts/")) (def grid-help-uri (obj/get global "penpotGridHelpURI" "https://help.penpot.app/user-guide/flexible-layouts/")) (def plugins-list-uri (obj/get global "penpotPluginsListURI" "https://penpot.app/penpothub/plugins")) diff --git a/frontend/src/app/main/data/auth.cljs b/frontend/src/app/main/data/auth.cljs index 41ff00a6b24..1c327895ea2 100644 --- a/frontend/src/app/main/data/auth.cljs +++ b/frontend/src/app/main/data/auth.cljs @@ -262,14 +262,21 @@ ptk/WatchEvent (watch [_ state _] - (let [profile-id (:profile-id state)] + (let [profile-id (:profile-id state) + ;; Strip the first subdomain so we land on the portal (outside ForwardAuth) + ;; instead of Penpot's own root, which would silently re-auth. + host (.-host js/location) + protocol (.-protocol js/location) + portal-host (.replace host #"^[^.]+\.(?=[^.]*\.[^.]*\.)" "") + portal-uri (str protocol "//" portal-host) + logged-out-ev (logged-out {:redirect-uri portal-uri})] (->> (rx/interval 500) (rx/take 1) (rx/mapcat (fn [_] (->> (rp/cmd! :logout {:profile-id profile-id}) (rx/delay-at-least 300) (rx/catch (constantly (rx/of nil)))))) - (rx/map logged-out)))))) + (rx/map (constantly logged-out-ev))))))) ;; --- Update Profile diff --git a/frontend/src/app/main/ui.cljs b/frontend/src/app/main/ui.cljs index 67795c2ff38..c1dafa5041e 100644 --- a/frontend/src/app/main/ui.cljs +++ b/frontend/src/app/main/ui.cljs @@ -153,6 +153,23 @@ section (get data :name) team (mf/deref refs/team) + ;; Dashboard / workspace pass ?team-id=… while `initialize-team` + ;; applies it asynchronously. On the first render `refs/team` can still + ;; be the personal default team, spuriously satisfying (:is-default team). + ;; Until state catches up, skip forcing team onboarding. + route-team-id (some-> (:query params) :team-id uuid/parse*) + current-team-id (:current-team-id (mf/deref st/state)) + team-route-synced? (or (nil? route-team-id) + (= route-team-id current-team-id)) + + ;; Forward-auth installs use :x-auth-request-headers and typically + ;; provision users onto a shared team; don't push "create a team". + ;; Also skip when get-teams has already populated a workspace team — + ;; URL ?team-id= often repeats the personal default id, which kept + ;; (:is-default team) true despite membership elsewhere. + user-has-shared-team? + (some #(and (some? %) (not (:is-default %))) + (vals (:teams (mf/deref st/state)))) show-question-modal? (and (contains? cf/flags :onboarding) @@ -163,7 +180,10 @@ (and (contains? cf/flags :onboarding) (not (:onboarding-viewed props)) (not (contains? props :onboarding-team-id)) - (:is-default team)) + (not (contains? cf/flags :x-auth-request-headers)) + (not user-has-shared-team?) + (:is-default team) + team-route-synced?) show-release-modal? (and (contains? cf/flags :onboarding) diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index 0273b239079..de05319defa 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -623,7 +623,12 @@ (dom/click!))))) close-teams-menu - (mf/use-fn #(reset! show-teams-menu* false))] + (mf/use-fn #(reset! show-teams-menu* false)) + + ;; SSO forward-auth setups provision teams via backend (auto-join). Hide dashboard + ;; "Create team" when enable-x-auth-request-headers is present in PENPOT_FLAGS (frontend config). + allow-dashboard-create-team? + (not (contains? cf/flags :x-auth-request-headers))] [:div {:class (stl/css :sidebar-team-switch)} [:div {:class (stl/css :switch-content)} @@ -676,7 +681,7 @@ :profile profile :teams teams :show-default-team true - :allow-create-teams true + :allow-create-teams allow-dashboard-create-team? :allow-create-org false}] [:> team-options-dropdown* {:show show-team-options-menu? diff --git a/frontend/src/app/main/ui/settings/profile.cljs b/frontend/src/app/main/ui/settings/profile.cljs index 763ee3c8364..bfae73f4a8e 100644 --- a/frontend/src/app/main/ui/settings/profile.cljs +++ b/frontend/src/app/main/ui/settings/profile.cljs @@ -44,10 +44,6 @@ form (fm/use-form :schema schema:profile-form :initial profile) - on-show-change-email - (mf/use-fn - #(modal/show! :change-email {})) - on-show-delete-account (mf/use-fn #(modal/show! :delete-account {}))] @@ -61,18 +57,16 @@ :name :fullname :label (tr "dashboard.your-name")}]] - [:div {:class (stl/css :fields-row) - :on-click on-show-change-email} + ;; Email is owned by the upstream IdP (oauth2-proxy / Cognito) — this + ;; fork has no scenario where it can be edited locally. Backend + ;; refuses :request-email-change unconditionally + ;; (rpc/commands/profile.clj + verify_token.clj). Render read-only. + [:div {:class (stl/css :fields-row)} [:& fm/input {:type "email" :name :email :disabled true - :label (tr "dashboard.your-email")}] - - [:div {:class (stl/css :options)} - [:div.change-email - [:a {:on-click on-show-change-email} - (tr "dashboard.change-email")]]]] + :label (tr "dashboard.your-email")}]] [:> fm/submit-button* {:label (tr "dashboard.save-settings")