🐛 Fix bad managed error on backend sso failure (#11247)

This commit is contained in:
Pablo Alba 2026-08-17 12:40:18 +02:00 committed by GitHub
parent 57c9c3f6a4
commit ed04d509ed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 487 additions and 30 deletions

View File

@ -261,23 +261,28 @@
(defn- wrap-nitrate-sso
"Enforce Nitrate organization SSO authentication for RPC handlers.
Resolves the organization/team context from request params using priority order:
1. Explicit :organization-id param
2. Explicit :team-id param
3. Explicit :project-id param -> lookup project.team_id
4. Explicit :file-id param -> lookup file's team via join
5. :id param dispatched by ::rpc/id-type metadata (:team, :project, or :file)
Resolves the organization/team context from request params:
1. Explicit :organization-id param identifies the organization directly
2. The team comes from the first available of: explicit :team-id, explicit
:project-id -> lookup project.team_id, explicit :file-id -> lookup file's
team via join, or the :id param dispatched by ::rpc/id-type metadata
(:team, :project, or :file)
Once the context is resolved, checks if the user is authorized within that organization's
SSO session using nitrate/sso-session-authorized?. Authorized results are cached
by [profile-id cache-ref] for 15 minutes to avoid repeated lookups.
SSO session using nitrate/sso-session-authorized?, against the organization when it is
known and against the team otherwise. The team is resolved either way, so the raised
error can carry it. Authorized results are cached by [profile-id cache-ref] for 15
minutes to avoid repeated lookups.
Only activates when:
- Nitrate flag is enabled
- Endpoint requires authentication (::auth true by default)
- Endpoint is not marked with ::nitrate/organization-sso false
Raises :nitrate-sso-required error if user is not authorized in the organization."
Raises :nitrate-sso-required error if user is not authorized in the organization.
The error carries the resolved :organization-id and :team-id so the client can
restart the SSO flow (via :check-nitrate-sso) instead of reporting a plain
permission failure."
[_ f mdata]
(if (and (contains? cf/flags :admin-console)
(::auth mdata true) ;; only for endpoints that needs auth
@ -302,17 +307,22 @@
cached (cache/get organization-sso-auth-cache cache-key)
result (if (some? cached)
cached
(let [team-id (when-not organization-id
(or team-id
(when project-id
(:team-id (db/get-by-id cfg :project project-id {:columns [:id :team-id]})))
;; The team is resolved even when the organization is
;; already known: the client needs it to restart the
;; SSO flow without sending non-members through the
;; organization's identity provider.
(let [team-id (or team-id
(when project-id
(:team-id (db/get-by-id cfg :project project-id {:columns [:id :team-id]})))
(when file-id
(:id (teams/get-team-for-file cfg file-id))))
request (-> (meta params) (get ::http/request))
{:keys [authorized sso]} (if organization-id
(nitrate/sso-session-authorized? cfg organization-id nil request)
(nitrate/sso-session-authorized? cfg nil team-id request))
entry {:authorized authorized
:organization-id (:organization-id sso)}]
:organization-id (or (:organization-id sso) organization-id)
:team-id team-id}]
(when authorized
(cache/get organization-sso-auth-cache cache-key (constantly entry)))
entry))]
@ -320,6 +330,8 @@
(f cfg params)
(ex/raise :type :authentication
:code :nitrate-sso-required
:organization-id (:organization-id result)
:team-id (:team-id result)
:hint "organization SSO authentication required")))
(f cfg params))))
f))

View File

@ -673,10 +673,15 @@
(sv/defmethod ::check-nitrate-sso
"Check if a user needs to login into the organization SSO.
Accepts either team-id (to look up the organization via the team) or organization-id directly.
Returns {:authorized true} when SSO is not active or the user cannot access the team.
Returns {:authorized true :reason :sso-satisfied} when SSO is not active or the
session already holds a valid entry for the organization, and
{:authorized true :reason :no-team-access} when the gate was skipped because the
user cannot access the team; the reason lets the client tell a usable session
apart from a plain permission failure.
Returns {:authorized false :redirect-uri <url>} when SSO is active;
the client must redirect there. The OIDC provider itself handles
re-authentication transparently if the user already has an active SSO session."
re-authentication transparently if the user already has an active SSO session.
A nil :redirect-uri means SSO is required but the provider is not usable."
{::rpc/auth true
::doc/added "2.18"
::sm/params schema:check-nitrate-sso
@ -687,11 +692,11 @@
(not (teams/has-read-permissions? cfg profile-id team-id)))
;; Let the destination RPC enforce its own permissions. Starting SSO before
;; access is established sends unrelated users through the organization's IdP.
{:authorized true}
{:authorized true :reason :no-team-access}
(let [request (rph/get-request params)
{:keys [authorized sso]} (nitrate/sso-session-authorized? cfg organization-id team-id request)]
(if authorized
{:authorized true}
{:authorized true :reason :sso-satisfied}
(if (oidc/organization-sso-discovery-uri sso)
{:authorized false
:redirect-uri (oidc/build-organization-sso-auth-redirect-uri cfg sso
@ -699,4 +704,4 @@
:organization-id organization-id)}
{:authorized false
:redirect-uri nil}))))
{:authorized true}))
{:authorized true :reason :sso-satisfied}))

View File

@ -15,15 +15,17 @@
[app.db :as-alias db]
[app.email :as eml]
[app.http :as-alias http]
[app.http.errors :as http-errors]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc]
[app.rpc :as rpc]
[app.rpc.commands.nitrate]
[app.rpc.commands.teams :as teams]
[app.rpc.helpers :as rph]
[backend-tests.helpers :as th]
[buddy.core.codecs :as bc]
[clojure.test :as t]
[cuerdas.core :as str]))
[cuerdas.core :as str]
[yetti.response :as-alias yres]))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
@ -87,6 +89,31 @@
nil)))
(defn- unauthorized-sso-mock
"Creates a mock for nitrate/sso-session-authorized? that reports an active
SSO the session does not satisfy. Pass nil to leave the organization out of
the nitrate payload."
[organization-id]
(fn [_cfg _organization-id _team-id _request]
{:authorized false
:sso (cond-> {:active true
:issuer "https://idp.example.com"}
(some? organization-id)
(assoc :organization-id organization-id))}))
(defn- sso-gate-error
"Builds the SSO gate around a handler that must never be reached, and
returns the exception it raises for `params`."
[mdata params cfg]
(let [handler (fn [_cfg _params] ::handler-called)
wrapped (binding [cf/flags (conj cf/flags :admin-console)]
(#'rpc/wrap-nitrate-sso nil handler mdata))]
(try
(wrapped cfg (with-meta params {::http/request {}}))
nil
(catch Throwable cause
cause))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Tests
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@ -112,7 +139,9 @@
(constantly "https://idp.example.com/authorize")]
(let [out (th/command! params)]
(t/is (th/success? out))
(t/is (= {:authorized true} (:result out))))))))
;; The reason tells the client this is a permission problem, not a
;; usable SSO session.
(t/is (= {:authorized true :reason :no-team-access} (:result out))))))))
(t/deftest check-nitrate-sso-keeps-gate-for-team-member
(let [team-owner (th/create-profile* 1 {:is-active true})
@ -169,6 +198,91 @@
:redirect-uri redirect-uri}
(:result out))))))))
(t/deftest check-nitrate-sso-reports-a-satisfied-gate-for-a-valid-session
(let [team-owner (th/create-profile* 1 {:is-active true})
team (th/create-team* 1 {:profile-id (:id team-owner)})
organization-id (uuid/random)
params (with-meta
{::th/type :check-nitrate-sso
::rpc/profile-id (:id team-owner)
:team-id (:id team)
:url "https://penpot.example.com/#/workspace"}
{::http/request {}})]
(binding [cf/flags (conj cf/flags :admin-console)]
(with-redefs [nitrate/sso-session-authorized?
(fn [_cfg _organization-id _team-id _request]
{:authorized true
:sso {:active true
:issuer "https://idp.example.com"
:organization-id organization-id}})]
(let [out (th/command! params)]
(t/is (th/success? out))
(t/is (= {:authorized true :reason :sso-satisfied} (:result out))))))))
(t/deftest nitrate-sso-required-error-resolves-the-team-from-the-file
(t/testing "the workspace path, where the file id arrives as :id, still reports the team"
(let [profile (th/create-profile* 1 {:is-active true})
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)})
organization-id (uuid/random)]
(with-redefs [nitrate/sso-session-authorized? (unauthorized-sso-mock organization-id)]
(let [data (ex-data (sso-gate-error {::rpc/id-type :file}
{::rpc/profile-id (:id profile)
:id (:id file)}
th/*system*))]
(t/is (= :authentication (:type data)))
(t/is (= :nitrate-sso-required (:code data)))
(t/is (= organization-id (:organization-id data)))
(t/is (= (:default-team-id profile) (:team-id data))))))))
(t/deftest nitrate-sso-required-error-keeps-the-team-known-by-the-request
(t/testing "an explicit team-id is not dropped by an explicit organization-id"
(let [profile-id (uuid/random)
team-id (uuid/random)
organization-id (uuid/random)]
;; The nitrate payload carries no organization-id here, so the one from
;; the request params is the only one left to report.
(with-redefs [nitrate/sso-session-authorized? (unauthorized-sso-mock nil)]
(let [data (ex-data (sso-gate-error {}
{::rpc/profile-id profile-id
:team-id team-id
:organization-id organization-id}
{}))]
(t/is (= organization-id (:organization-id data)))
(t/is (= team-id (:team-id data))))))))
(t/deftest nitrate-sso-required-error-resolves-the-team-with-a-known-organization
(t/testing "knowing the organization does not stop the team lookup"
(let [profile (th/create-profile* 1 {:is-active true})
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)})
organization-id (uuid/random)]
(with-redefs [nitrate/sso-session-authorized? (unauthorized-sso-mock nil)]
(let [data (ex-data (sso-gate-error {}
{::rpc/profile-id (:id profile)
:organization-id organization-id
:file-id (:id file)}
th/*system*))]
(t/is (= organization-id (:organization-id data)))
(t/is (= (:default-team-id profile) (:team-id data))))))))
(t/deftest nitrate-sso-required-error-reaches-the-client-in-the-401-body
(t/testing "the ids survive the http error response, not only the exception"
(let [profile-id (uuid/random)
team-id (uuid/random)
organization-id (uuid/random)]
(with-redefs [nitrate/sso-session-authorized? (unauthorized-sso-mock organization-id)]
(let [cause (sso-gate-error {}
{::rpc/profile-id profile-id
:team-id team-id}
{})
response (http-errors/handle cause {})
body (::yres/body response)]
(t/is (= 401 (::yres/status response)))
(t/is (= :nitrate-sso-required (:code body)))
(t/is (= organization-id (:organization-id body)))
(t/is (= team-id (:team-id body))))))))
(t/deftest leave-organization-happy-path-no-extra-teams
(let [profile-owner (th/create-profile* 1 {:is-active true})
profile-user (th/create-profile* 2 {:is-active true})

View File

@ -352,19 +352,30 @@
(rx/empty)))))))))))
(defn check-organization-sso
"Asks the backend whether the organization SSO gate can be satisfied for
`dest-url`, returning an observable of the raw `:check-nitrate-sso`
result: `:authorized` with a `:reason` of `:sso-satisfied` or
`:no-team-access`, or `:authorized false` with a `:redirect-uri` (nil
when SSO is required but the provider is unusable). Failures are not
caught, so a network blip stays a network error for the caller to
handle instead of masquerading as an answer."
[{:keys [team-id organization-id dest-url]}]
(rp/cmd! :check-nitrate-sso (d/without-nils {:team-id team-id
:organization-id organization-id
:url dest-url})))
(defn retry-organization-sso
"Retries the organization SSO login flow after a failed attempt, reusing
the same check-nitrate-sso RPC used elsewhere to move the user through
the organization's identity provider. Passing `team-id` enables the
backend's non-member short-circuit. Falls back to navigating straight
to `dest-url` when no fresh SSO redirect is needed or available."
[{:keys [team-id organization-id dest-url]}]
[{:keys [dest-url] :as params}]
(ptk/reify ::retry-organization-sso
ptk/WatchEvent
(watch [_ _ _]
(->> (rp/cmd! :check-nitrate-sso (d/without-nils {:team-id team-id
:organization-id organization-id
:url dest-url}))
(->> (check-organization-sso params)
(rx/map (fn [{:keys [redirect-uri]}]
(rt/nav-raw :uri (or redirect-uri dest-url))))
(rx/catch (fn [_]

View File

@ -13,6 +13,7 @@
[app.main.data.auth :as da]
[app.main.data.event :as ev]
[app.main.data.modal :as modal]
[app.main.data.nitrate :as dnt]
[app.main.data.notifications :as ntf]
[app.main.data.workspace :as-alias dw]
[app.main.router :as rt]
@ -21,6 +22,7 @@
[app.util.globals :as g]
[app.util.i18n :refer [tr]]
[app.util.timers :as ts]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[potok.v2.core :as ptk]))
@ -234,9 +236,8 @@
;; We receive a explicit authentication error; If the uri is for
;; workspace, dashboard, viewer or settings, then assign the exception
;; for show the error page. Otherwise this explicitly clears all
;; profile data and redirect the user to the login page. This is here
;; and not in app.main.errors because of circular dependency.
(defmethod ptk/handle-error :authentication
;; profile data and redirect the user to the login page.
(defn- show-authentication-error
[error]
(let [message (tr "errors.auth.unable-to-login")
uri (rt/get-current-href)
@ -253,6 +254,85 @@
(st/emit! (da/logout))
(ts/schedule 500 #(st/emit! (ntf/warn message)))))))
;; The user does belong to an organization with SSO active, but there is
;; no provider to send them to (unusable or incomplete SSO config). Show
;; the SSO error dialog, which offers an explicit retry, rather than
;; claiming they have no access.
(defn- show-sso-error
[{:keys [organization-id team-id]}]
(let [uri (rt/get-current-href)]
(st/async-emit!
(rt/assign-exception {:type :sso-error
:organization-id organization-id
:team-id team-id
:is-workspace (str/includes? uri "workspace")
:is-dashboard (str/includes? uri "dashboard")}))))
;; A page issues many SSO-guarded requests at once, and all of them fail
;; together the moment the organization SSO session lapses; without this
;; only-one-in-flight guard each of them would start its own identity
;; provider round-trip.
(def ^:private sso-renewal-pending? (volatile! false))
(defn- renew-organization-sso
"Recover from a request rejected by the organization SSO gate.
Asks the backend what can be done for the current location and acts on
the answer: go through the identity provider when there is one (it
re-authenticates transparently while the user still has a live session
with it), retry the location when the gate turns out to be satisfied
already (another tab renewed the session, or SSO was turned off), show
the SSO error dialog when SSO is required but unusable, and report a
permission failure only when the user really has no access to the team.
A failing check is left to the generic error handling, so a network
blip is not turned into a permission error."
[{:keys [organization-id team-id] :as error}]
(when-not @sso-renewal-pending?
(vreset! sso-renewal-pending? true)
(let [dest-url (rt/get-current-href)]
(->> (dnt/check-organization-sso
{:organization-id organization-id
:team-id team-id
:dest-url dest-url})
;; Release the guard however the check ends, including an
;; unsubscription or a completion without a result: a stuck guard
;; would silently drop every later rejection.
(rx/finalize (fn [] (vreset! sso-renewal-pending? false)))
(rx/subs! (fn [{:keys [authorized reason redirect-uri]}]
(cond
;; SSO must be renewed and we know where to send them
(some? redirect-uri)
(st/emit! (rt/nav-raw :uri (str redirect-uri)))
;; The gate is satisfied after all, so the request
;; that failed can be retried. Only an affirmative
;; reason is accepted here: reloading on any
;; unrecognized "authorized" answer would spin
;; whenever the reload hits the same rejection.
(= :sso-satisfied reason)
(st/emit! (rt/reload false))
;; SSO is required but the provider is unusable
(not authorized)
(show-sso-error error)
;; No access to the team, so the gate was never
;; evaluated: this really is a permission failure
:else
(show-authentication-error error)))
on-error)))))
(defmethod ptk/handle-error :authentication
[error]
;; Without an organization or a team there is nothing to check, and asking
;; anyway would fail schema validation and report that instead of the
;; authentication problem the user actually hit.
(if (and (= :nitrate-sso-required (get error :code))
(or (some? (get error :organization-id))
(some? (get error :team-id))))
(renew-organization-sso error)
(show-authentication-error error)))
;; Error that happens on an active business model validation does not
;; passes an validation (example: profile can't leave a team). From
;; the user perspective a error flash message should be visualized but

View File

@ -11,10 +11,17 @@
- stale-asset-error? pure predicate
- exception->error-data pure transformer
- on-error re-entrancy guard prevents recursive invocations
- flash schedules async emit ntf/show is not emitted synchronously"
- flash schedules async emit ntf/show is not emitted synchronously
- organization SSO recovery expired SSO sessions go back to the provider"
(:require
[app.main.errors :as errors]
[app.main.repo :as rp]
[app.main.router :as rt]
[app.main.store :as st]
[app.util.timers :as tm]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.mock :as mock]
[potok.v2.core :as ptk]))
;; ---------------------------------------------------------------------------
@ -134,3 +141,231 @@
(errors/on-error (ex-info "test" {:type ::test-reentrant :hint "first"}))
;; The guard must have allowed only the first invocation through.
(t/is (= 1 @reentrant-call-count))))
;; ---------------------------------------------------------------------------
;; Expired organization SSO session
;;
;; The backend rejects SSO-guarded requests with an :authentication error
;; coded :nitrate-sso-required once the organization SSO session lapses.
;; The user must be sent back through the identity provider instead of
;; being told they have no access to the file.
;; ---------------------------------------------------------------------------
(def ^:private workspace-href
"https://penpot.example.com/#/workspace?team-id=b8f8bb52-8b70-8144-8004-4a5085f0bdc9")
(def ^:private organization-id "d1a4c0f2-2f36-8114-8006-1b0e6d9d0c11")
(defn- sso-required-error
[]
{:type :authentication
:code :nitrate-sso-required
:organization-id organization-id
:team-id "b8f8bb52-8b70-8144-8004-4a5085f0bdc9"})
(t/deftest expired-organization-sso-navigates-to-identity-provider
(t/testing "the browser is sent to the identity provider instead of an error page"
(let [events (atom [])]
(with-redefs [rp/cmd!
(mock/stub
(fn [_command _params]
(rx/of {:authorized false
:redirect-uri "https://idp.example.com/authorize"})))
rt/get-current-href
(constantly workspace-href)
st/emit!
(mock/stub (fn [& emitted] (swap! events into emitted)))]
(errors/on-error (sso-required-error))
(t/is (= [::rt/nav-raw] (mapv ptk/type @events)))))))
(t/deftest expired-organization-sso-comes-back-to-the-current-location
(t/testing "the SSO check asks the provider to return the user where they were"
(let [rpc-calls (atom [])]
(with-redefs [rp/cmd!
(mock/stub
(fn [command params]
(swap! rpc-calls conj {:command command :params params})
(rx/of {:authorized false
:redirect-uri "https://idp.example.com/authorize"})))
rt/get-current-href
(constantly workspace-href)
st/emit! mock/noop]
(errors/on-error (sso-required-error))
(t/is (= [{:command :check-nitrate-sso
:params {:team-id "b8f8bb52-8b70-8144-8004-4a5085f0bdc9"
:organization-id organization-id
:url workspace-href}}]
@rpc-calls))))))
(t/deftest already-satisfied-organization-sso-retries-the-location
(t/testing "a session renewed meanwhile (e.g. in another tab) reloads instead of erroring"
(let [events (atom [])]
(with-redefs [rp/cmd!
(mock/stub
(fn [_command _params]
(rx/of {:authorized true :reason :sso-satisfied})))
rt/get-current-href
(constantly workspace-href)
st/emit!
(mock/stub (fn [& emitted] (swap! events into emitted)))]
(errors/on-error (sso-required-error))
(t/is (= [::rt/reload] (mapv ptk/type @events)))))))
(t/deftest organization-sso-without-usable-provider-shows-the-sso-error-dialog
(t/testing "SSO is required but there is nowhere to go: offer a retry, not a permission error"
(let [assigned* (atom nil)]
(with-redefs [rp/cmd!
(mock/stub
(fn [_command _params]
(rx/of {:authorized false :redirect-uri nil})))
rt/get-current-href
(constantly workspace-href)
rt/assign-exception
(fn [error]
(reset! assigned* error)
(ptk/data-event ::assigned error))]
(errors/on-error (sso-required-error))
(t/is (= :sso-error (:type @assigned*)))
(t/is (= organization-id (:organization-id @assigned*)))
(t/is (true? (:is-workspace @assigned*)))))))
(t/deftest organization-sso-without-team-access-reports-a-permission-failure
(t/testing "a user who cannot reach the team keeps getting the authentication error"
(let [assigned* (atom nil)]
(with-redefs [rp/cmd!
(mock/stub
(fn [_command _params]
(rx/of {:authorized true :reason :no-team-access})))
rt/get-current-href
(constantly workspace-href)
rt/assign-exception
(fn [error]
(reset! assigned* error)
(ptk/data-event ::assigned error))]
(errors/on-error (sso-required-error))
(t/is (= :authentication (:type @assigned*)))
(t/is (= :nitrate-sso-required (:code @assigned*)))))))
(t/deftest organization-sso-does-not-retry-on-an-unexplained-authorization
(t/testing "reloading on an answer we don't understand would spin on the same rejection"
(let [events (atom [])]
(with-redefs [rp/cmd!
(mock/stub (fn [_command _params] (rx/of {:authorized true})))
rt/get-current-href
(constantly workspace-href)
rt/assign-exception
(fn [error] (ptk/data-event ::assigned error))
;; async-emit! is variadic-only, so the replacement must be
;; variadic too for the compiled static dispatch to find it
st/async-emit!
(fn [& emitted] (swap! events into emitted))]
(errors/on-error (sso-required-error))
(t/is (= [::assigned] (mapv ptk/type @events)))))))
(t/deftest organization-sso-error-without-context-is-reported-as-it-arrives
(t/testing "with no organization and no team there is nothing to check"
(let [rpc-calls (atom 0)
assigned* (atom nil)]
(with-redefs [rp/cmd!
(mock/stub (fn [_command _params]
(swap! rpc-calls inc)
(rx/empty)))
rt/get-current-href
(constantly workspace-href)
rt/assign-exception
(fn [error]
(reset! assigned* error)
(ptk/data-event ::assigned error))]
(errors/on-error {:type :authentication
:code :nitrate-sso-required})
(t/is (zero? @rpc-calls))
(t/is (= :nitrate-sso-required (:code @assigned*)))))))
(t/deftest a-resultless-organization-sso-check-does-not-wedge-later-rejections
(t/testing "the one-in-flight guard is released even when no answer arrives"
(let [rpc-calls (atom 0)]
(with-redefs [rp/cmd!
(mock/stub (fn [_command _params]
(swap! rpc-calls inc)
(rx/empty)))
rt/get-current-href
(constantly workspace-href)
st/emit! mock/noop]
(errors/on-error (sso-required-error))
(errors/on-error (sso-required-error))
(t/is (= 2 @rpc-calls))))))
;; A failing check must stay a failing check: the generic handling turns it
;; into a toast, whereas swallowing it would show a permission error for
;; what may be a momentary network blip. The mocked RPC fails on a later
;; tick, like a real request, so the handler is not inside on-error's
;; re-entrancy guard when the failure arrives.
(def ^:private check-failures (atom []))
(defmethod ptk/handle-error ::test-check-failure
[error]
(swap! check-failures conj error))
(t/deftest failing-organization-sso-check-is-not-reported-as-missing-access
(t/async done
(reset! check-failures [])
(let [assigned* (atom nil)]
(mock/with-mocks
{rp/cmd!
(mock/stub
(fn [_command _params]
(->> (rx/timer 0)
(rx/mapcat (fn [_]
(rx/throw (ex-info "boom" {:type ::test-check-failure})))))))
rt/get-current-href
(constantly workspace-href)
rt/assign-exception
(fn [error]
(reset! assigned* error)
(ptk/data-event ::assigned error))}
(fn [done']
(errors/on-error (sso-required-error))
(tm/schedule
50
(fn []
(t/is (= [::test-check-failure] (mapv :type @check-failures)))
(t/is (nil? @assigned*))
(done'))))
done))))