From 02c31e734892aadc3b4165230ab7325798778b60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marina=20L=C3=B3pez?= Date: Tue, 11 Aug 2026 13:30:04 +0200 Subject: [PATCH] :bug: Cache Nitrate SSO checks during navigation (#11209) --- frontend/src/app/main/ui/routes.cljs | 52 +++++++++--- frontend/test/frontend_tests/runner.cljs | 2 + .../test/frontend_tests/ui/routes_test.cljs | 85 +++++++++++++++++++ 3 files changed, 125 insertions(+), 14 deletions(-) create mode 100644 frontend/test/frontend_tests/ui/routes_test.cljs diff --git a/frontend/src/app/main/ui/routes.cljs b/frontend/src/app/main/ui/routes.cljs index 5b5c985043..cc0d07537d 100644 --- a/frontend/src/app/main/ui/routes.cljs +++ b/frontend/src/app/main/ui/routes.cljs @@ -7,6 +7,7 @@ (ns app.main.ui.routes (:require [app.common.data.macros :as dm] + [app.common.time :as ct] [app.common.uri :as u] [app.common.uuid :as uuid] [app.config :as cf] @@ -21,6 +22,12 @@ [cuerdas.core :as str] [potok.v2.core :as ptk])) +(def ^:private sso-authorization-max-age-ms + (* 5 60 1000)) + +(defonce ^:private sso-authorization-cache + (atom {})) + (def routes [["/auth" ["/login" :auth-login] @@ -102,26 +109,43 @@ "Authorization filter for dashboard and workspace routes. Checks if the team being navigated to has an organization with SSO active. If so, calls :check-nitrate-sso and either proceeds with navigation - or redirects to the SSO provider URL." + or redirects to the SSO provider URL. Successful checks are cached for five + minutes per profile and team; redirect results are never cached." [match send-event-info? url] - (let [route-name (name (get-in match [:data :name])) - relevant? (and (contains? cf/flags :admin-console) - (or (str/starts-with? route-name "dashboard") - (str/starts-with? route-name "workspace"))) - team-id-str (when relevant? - (or (get-in match [:query-params :team-id]) - (get-in match [:params :path :team-id]))) - team-id (some-> team-id-str uuid/parse*)] - (if (some? team-id) + (let [route-name (name (get-in match [:data :name])) + relevant? (and (contains? cf/flags :admin-console) + (or (str/starts-with? route-name "dashboard") + (str/starts-with? route-name "workspace"))) + team-id-str (when relevant? + (or (get-in match [:query-params :team-id]) + (get-in match [:params :path :team-id]))) + team-id (some-> team-id-str uuid/parse*) + profile-id (get-in @st/state [:profile :id]) + cache-key [profile-id team-id] + authorized-at (get @sso-authorization-cache cache-key) + cache-valid? (and (some? authorized-at) + (< (ct/diff-ms authorized-at (ct/now)) + sso-authorization-max-age-ms)) + navigate #(st/emit! (rt/navigated match send-event-info?))] + (cond + (nil? team-id) + (navigate) + + cache-valid? + (navigate) + + :else (->> (rp/cmd! :check-nitrate-sso {:team-id team-id :url url}) (rx/subs! (fn [{:keys [authorized redirect-uri]}] (if authorized - (st/emit! (rt/navigated match send-event-info?)) - (when redirect-uri (st/emit! (rt/nav-raw :uri (str redirect-uri)))))) + (do + (swap! sso-authorization-cache assoc cache-key (ct/now)) + (navigate)) + (when redirect-uri + (st/emit! (rt/nav-raw :uri (str redirect-uri)))))) (fn [cause] - (errors/on-error cause)))) - (st/emit! (rt/navigated match send-event-info?))))) + (errors/on-error cause))))))) (defn- handle-sso-error-and-navigate "Check if the current route has an SSO error marker. If so, assign an diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 7c0c5f0889..a29132a22e 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -72,6 +72,7 @@ [frontend-tests.ui.gradient-handlers-test] [frontend-tests.ui.layout-container-multiple-test] [frontend-tests.ui.measures-menu-props-test] + [frontend-tests.ui.routes-test] [frontend-tests.ui.settings-password-schema-test] [frontend-tests.ui.settings-shortcuts-test] [frontend-tests.util-clipboard-test] @@ -163,6 +164,7 @@ 'frontend-tests.ui.gradient-handlers-test 'frontend-tests.ui.layout-container-multiple-test 'frontend-tests.ui.measures-menu-props-test + 'frontend-tests.ui.routes-test 'frontend-tests.render-dimensions-test 'frontend-tests.text-editor-paste-guard-test 'frontend-tests.ui.settings-password-schema-test diff --git a/frontend/test/frontend_tests/ui/routes_test.cljs b/frontend/test/frontend_tests/ui/routes_test.cljs new file mode 100644 index 0000000000..3ebb7edbbe --- /dev/null +++ b/frontend/test/frontend_tests/ui/routes_test.cljs @@ -0,0 +1,85 @@ +;; 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 Sucursal en EspaƱa SL + +(ns frontend-tests.ui.routes-test + (:require + [app.common.time :as ct] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.main.repo :as rp] + [app.main.store :as st] + [app.main.ui.routes :as routes] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.mock :as mock])) + +(defn- workspace-match + [team-id] + {:data {:name :workspace} + :params {:path {}} + :query-params {:team-id (str team-id)}}) + +(t/deftest sso-check-is-cached-for-five-minutes + (let [team-id (uuid/next) + match (workspace-match team-id) + now (atom (ct/inst "2026-08-11T10:00:00Z")) + rpc-calls (atom 0) + events (atom [])] + (with-redefs [cf/flags (conj cf/flags :admin-console) + ct/now (mock/stub (fn [] @now)) + rp/cmd! (mock/stub + (fn [command params] + (t/is (= :check-nitrate-sso command)) + (t/is (= team-id (:team-id params))) + (swap! rpc-calls inc) + (rx/of {:authorized true}))) + st/emit! (mock/stub + (fn [& emitted] + (swap! events into emitted)))] + (#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace") + (reset! now (ct/plus @now #js {:minutes 4 :seconds 59})) + (#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace") + + (t/is (= 1 @rpc-calls)) + (t/is (= 2 (count @events)))))) + +(t/deftest sso-check-is-refreshed-after-five-minutes + (let [team-id (uuid/next) + match (workspace-match team-id) + now (atom (ct/inst "2026-08-11T10:00:00Z")) + rpc-calls (atom 0)] + (with-redefs [cf/flags (conj cf/flags :admin-console) + ct/now (mock/stub (fn [] @now)) + rp/cmd! (mock/stub + (fn [_ _] + (swap! rpc-calls inc) + (rx/of {:authorized true}))) + st/emit! mock/noop] + (#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace") + (reset! now (ct/plus @now #js {:minutes 5})) + (#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace") + + (t/is (= 2 @rpc-calls))))) + +(t/deftest sso-redirect-result-is-not-cached + (let [team-id (uuid/next) + match (workspace-match team-id) + rpc-calls (atom 0) + events (atom [])] + (with-redefs [cf/flags (conj cf/flags :admin-console) + rp/cmd! (mock/stub + (fn [_ _] + (swap! rpc-calls inc) + (rx/of {:authorized false + :redirect-uri "https://idp.example.com/authorize"}))) + st/emit! (mock/stub + (fn [& emitted] + (swap! events into emitted)))] + (#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace") + (#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace") + + (t/is (= 2 @rpc-calls)) + (t/is (= 2 (count @events))))))