🐛 Cache Nitrate SSO checks during navigation (#11209)

This commit is contained in:
Marina López 2026-08-11 13:30:04 +02:00 committed by GitHub
parent 69ef7e86cd
commit 02c31e7348
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 125 additions and 14 deletions

View File

@ -7,6 +7,7 @@
(ns app.main.ui.routes (ns app.main.ui.routes
(:require (:require
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.time :as ct]
[app.common.uri :as u] [app.common.uri :as u]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.config :as cf] [app.config :as cf]
@ -21,6 +22,12 @@
[cuerdas.core :as str] [cuerdas.core :as str]
[potok.v2.core :as ptk])) [potok.v2.core :as ptk]))
(def ^:private sso-authorization-max-age-ms
(* 5 60 1000))
(defonce ^:private sso-authorization-cache
(atom {}))
(def routes (def routes
[["/auth" [["/auth"
["/login" :auth-login] ["/login" :auth-login]
@ -102,26 +109,43 @@
"Authorization filter for dashboard and workspace routes. "Authorization filter for dashboard and workspace routes.
Checks if the team being navigated to has an organization with SSO Checks if the team being navigated to has an organization with SSO
active. If so, calls :check-nitrate-sso and either proceeds with navigation 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] [match send-event-info? url]
(let [route-name (name (get-in match [:data :name])) (let [route-name (name (get-in match [:data :name]))
relevant? (and (contains? cf/flags :admin-console) relevant? (and (contains? cf/flags :admin-console)
(or (str/starts-with? route-name "dashboard") (or (str/starts-with? route-name "dashboard")
(str/starts-with? route-name "workspace"))) (str/starts-with? route-name "workspace")))
team-id-str (when relevant? team-id-str (when relevant?
(or (get-in match [:query-params :team-id]) (or (get-in match [:query-params :team-id])
(get-in match [:params :path :team-id]))) (get-in match [:params :path :team-id])))
team-id (some-> team-id-str uuid/parse*)] team-id (some-> team-id-str uuid/parse*)
(if (some? team-id) 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}) (->> (rp/cmd! :check-nitrate-sso {:team-id team-id :url url})
(rx/subs! (rx/subs!
(fn [{:keys [authorized redirect-uri]}] (fn [{:keys [authorized redirect-uri]}]
(if authorized (if authorized
(st/emit! (rt/navigated match send-event-info?)) (do
(when redirect-uri (st/emit! (rt/nav-raw :uri (str redirect-uri)))))) (swap! sso-authorization-cache assoc cache-key (ct/now))
(navigate))
(when redirect-uri
(st/emit! (rt/nav-raw :uri (str redirect-uri))))))
(fn [cause] (fn [cause]
(errors/on-error cause)))) (errors/on-error cause)))))))
(st/emit! (rt/navigated match send-event-info?)))))
(defn- handle-sso-error-and-navigate (defn- handle-sso-error-and-navigate
"Check if the current route has an SSO error marker. If so, assign an "Check if the current route has an SSO error marker. If so, assign an

View File

@ -72,6 +72,7 @@
[frontend-tests.ui.gradient-handlers-test] [frontend-tests.ui.gradient-handlers-test]
[frontend-tests.ui.layout-container-multiple-test] [frontend-tests.ui.layout-container-multiple-test]
[frontend-tests.ui.measures-menu-props-test] [frontend-tests.ui.measures-menu-props-test]
[frontend-tests.ui.routes-test]
[frontend-tests.ui.settings-password-schema-test] [frontend-tests.ui.settings-password-schema-test]
[frontend-tests.ui.settings-shortcuts-test] [frontend-tests.ui.settings-shortcuts-test]
[frontend-tests.util-clipboard-test] [frontend-tests.util-clipboard-test]
@ -163,6 +164,7 @@
'frontend-tests.ui.gradient-handlers-test 'frontend-tests.ui.gradient-handlers-test
'frontend-tests.ui.layout-container-multiple-test 'frontend-tests.ui.layout-container-multiple-test
'frontend-tests.ui.measures-menu-props-test 'frontend-tests.ui.measures-menu-props-test
'frontend-tests.ui.routes-test
'frontend-tests.render-dimensions-test 'frontend-tests.render-dimensions-test
'frontend-tests.text-editor-paste-guard-test 'frontend-tests.text-editor-paste-guard-test
'frontend-tests.ui.settings-password-schema-test 'frontend-tests.ui.settings-password-schema-test

View File

@ -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))))))