🐛 Fix SSO failure logging user out instead of showing error page

This commit is contained in:
María Valderrama 2026-08-07 10:58:40 +02:00
parent bc9319eac5
commit 97fae72ba6
6 changed files with 185 additions and 49 deletions

View File

@ -650,6 +650,20 @@
(assoc :query (u/map->query-string params)))]
(redirect-response uri))))
(defn- redirect-with-organization-sso-error
[{:keys [dest-url organization-id]}]
(let [uri (u/uri (or dest-url (cf/get :public-uri)))
params (d/without-nils {:sso-error true :organization-id organization-id})
qs (u/map->query-string params)
fragment (or (:fragment uri) "")
parsed-frag (when-not (str/blank? fragment) (u/parse fragment))
new-fragment (if parsed-frag
(let [existing-query (or (:query parsed-frag) "")
combined (if (str/blank? existing-query) qs (str existing-query "&" qs))]
(str (u/uri (assoc parsed-frag :query combined))))
(str "?" qs))]
(redirect-response (assoc uri :fragment new-fragment))))
(defn- redirect-to-register
[cfg info provider]
(let [info (assoc info
@ -899,17 +913,32 @@
;; Organization SSO flow: state carries :dest-url — exchange the authorization
;; code with the OIDC provider to verify authentication actually occurred.
(if-let [dest-url (:dest-url state)]
(let [organization-id (:organization-id state)
sso (nitrate/call cfg :get-organization-sso {:organization-id organization-id})
provider (prepare-organization-sso-provider cfg sso)
info (get-info cfg provider state code)
session (session/get-session request)
exp (or (:sso-token-exp info) (ct/in-future {:hours 48}))]
(when (and session organization-id)
(let [props (-> (or (:props session) {})
(update :sso assoc organization-id exp))]
(session/update-session (::session/manager cfg) (assoc session :props props))))
(redirect-response dest-url))
(try
(let [organization-id (:organization-id state)
sso (nitrate/call cfg :get-organization-sso {:organization-id organization-id})
provider (prepare-organization-sso-provider cfg sso)
info (get-info cfg provider state code)
session (session/get-session request)
exp (or (:sso-token-exp info) (ct/in-future {:hours 48}))]
(when (and session organization-id)
(let [props (-> (or (:props session) {})
(update :sso assoc organization-id exp))]
(session/update-session (::session/manager cfg) (assoc session :props props))))
(redirect-response dest-url))
(catch Throwable cause
(let [{:keys [code]} (ex-data cause)]
(binding [l/*context* (errors/request->context request)]
(if (some? code)
(l/warn :hint "organization sso callback failed"
:code code
:message (ex-message cause)
:organization-id (:organization-id state))
(l/err :hint "unexpected error on organization sso callback"
:organization-id (:organization-id state)
:cause cause))))
(redirect-with-organization-sso-error
{:dest-url dest-url
:organization-id (:organization-id state)})))
(let [provider (resolve-provider cfg state)
info (get-info cfg provider state code)

View File

@ -351,6 +351,21 @@
(rx/empty)))))))))))
(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. Falls back to navigating straight
to `dest-url` when no fresh SSO redirect is needed or available."
[{:keys [organization-id dest-url]}]
(ptk/reify ::retry-organization-sso
ptk/WatchEvent
(watch [_ _ _]
(->> (rp/cmd! :check-nitrate-sso {:organization-id organization-id :url dest-url})
(rx/map (fn [{:keys [redirect-uri]}]
(rt/nav-raw :uri (or redirect-uri dest-url))))
(rx/catch (fn [_]
(rx/of (rt/nav-raw :uri dest-url))))))))
(defn- fetch-organizations-allowed
"Returns an rx observable of an `organizations-allowed` map (organization-id -> boolean).
Organizations where :add-anybody-to-team is permitted are pre-approved;

View File

@ -123,6 +123,27 @@
(errors/on-error cause))))
(st/emit! (rt/navigated match send-event-info?)))))
(defn- handle-sso-error-and-navigate
"Check if the current route has an SSO error marker. If so, assign an
exception with type :sso-error and organization-id from query params,
then proceed with normal navigation."
[match send-event-info? url]
(let [route-name (name (get-in match [:data :name]))
sso-error? (some? (get-in match [:query-params :sso-error]))
organization-id (some-> (get-in match [:query-params :organization-id]) uuid/parse*)
team-id-str (or (get-in match [:query-params :team-id])
(get-in match [:params :path :team-id])) ;; Fallback: team-id may be in path params for workspace routes
team-id (some-> team-id-str uuid/parse*)
is-workspace? (str/starts-with? route-name "workspace")
is-dashboard? (str/starts-with? route-name "dashboard")]
(if sso-error?
(st/emit! (rt/assign-exception {:type :sso-error
:organization-id organization-id
:team-id team-id
:is-workspace is-workspace?
:is-dashboard is-dashboard?}))
(check-sso-and-navigate match send-event-info? url))))
(defn on-navigate
[router path send-event-info?]
(let [location (.-location js/document)
@ -138,7 +159,7 @@
(st/emit! (rt/assign-exception {:type :not-found}))
(some? match)
(check-sso-and-navigate match send-event-info? (rt/get-current-href))
(handle-sso-error-and-navigate match send-event-info? (rt/get-current-href))
:else
;; We just recheck with an additional profile request; this

View File

@ -13,6 +13,7 @@
[app.common.uuid :as uuid]
[app.main.data.auth :refer [is-authenticated?]]
[app.main.data.common :as dcm]
[app.main.data.nitrate :as dnt]
[app.main.errors :as errors]
[app.main.refs :as refs]
[app.main.repo :as rp]
@ -433,43 +434,6 @@
(rx/of default)
(rx/throw cause)))))))
(mf/defc exception-section*
{::mf/private true}
[{:keys [data] :as props}]
(let [type (get data :type)
cause (get data ::errors/instance)
report (mf/with-memo [cause]
(when (ex/exception? cause)
(errors/generate-report cause)))
props (mf/spread-props props {:report report})]
(mf/with-effect [report type cause]
(when (and (ex/exception? cause)
(not (contains? #{:not-found :authentication} type)))
(errors/submit-report :event-name "exception-page"
:report report
:hint (ex/get-hint cause))))
(case type
:not-found
[:> not-found* {}]
:authentication
[:> not-found* {}]
:bad-gateway
[:> bad-gateway* props]
:service-unavailable
[:> service-unavailable*]
:nitrate-unavailable
[:> nitrate-unavailable*]
[:> internal-error* props])))
(mf/defc context-wrapper*
[{:keys [is-workspace is-dashboard is-viewer profile children]}]
[:*
@ -515,6 +479,95 @@
children])
(mf/defc sso-error-section*
"Shown in place of the dashboard/workspace (same static skeleton and
`request-dialog*` used by the no-permission dialogs) when the organization
SSO exchange with the identity provider fails."
[{:keys [organization-id team-id profile is-workspace is-dashboard]}]
(let [clean-url
(mf/with-memo []
(-> (rt/get-current-href)
(dom/remove-query-param :sso-error)
(dom/remove-query-param :organization-id)))
_ (mf/with-effect []
;; Consume the marker once: scrub it from the URL bar so a
;; browser refresh doesn't keep re-showing this dialog.
(dom/replace-history-state! clean-url))
on-close
(mf/use-fn
(mf/deps profile)
(fn []
;; Land on the user's own default team
(st/emit! (dcm/go-to-dashboard-recent :team-id (:default-team-id profile)))))
on-retry
(mf/use-fn
(mf/deps organization-id team-id clean-url)
(fn []
(if team-id
;; Retry with team-id to trigger SSO check for that specific team
(st/emit! (dnt/retry-organization-sso {:organization-id organization-id
:dest-url clean-url}))
;; Fallback: just navigate to clean URL
(st/emit! (rt/nav-raw :uri clean-url)))))]
[:> context-wrapper* {:is-dashboard (or is-dashboard (not is-workspace))
:is-workspace is-workspace
:profile profile}
[:> request-dialog* {:title (tr "labels.sso-error.title")
:content [(tr "labels.sso-error.desc-message")]
:button-text (tr "labels.sso-error.retry")
:on-button-click on-retry
:cancel-text (tr "not-found.no-permission.go-dashboard")
:on-close on-close}]]))
(mf/defc exception-section*
{::mf/private true}
[{:keys [data] :as props}]
(let [type (get data :type)
cause (get data ::errors/instance)
organization-id (get data :organization-id)
report (mf/with-memo [cause]
(when (ex/exception? cause)
(errors/generate-report cause)))
props (mf/spread-props props {:report report})]
(mf/with-effect [report type cause]
(when (and (ex/exception? cause)
(not (contains? #{:not-found :authentication} type)))
(errors/submit-report :event-name "exception-page"
:report report
:hint (ex/get-hint cause))))
(case type
:not-found
[:> not-found* {}]
:authentication
[:> not-found* {}]
:bad-gateway
[:> bad-gateway* props]
:service-unavailable
[:> service-unavailable*]
:nitrate-unavailable
[:> nitrate-unavailable*]
:sso-error
[:> sso-error-section* {:organization-id organization-id
:team-id (get data :team-id)
:profile (mf/deref refs/profile)
:is-workspace (get data :is-workspace false)
:is-dashboard (get data :is-dashboard true)}]
[:> internal-error* props])))
(mf/defc exception-page*
[{:keys [data route] :as props}]

View File

@ -10339,3 +10339,12 @@ msgstr "Click to close the path"
msgid "notifications.invitation-canceled"
msgstr "This invitation is no longer available."
msgid "labels.sso-error.title"
msgstr "We couldn't sign you in to your organization"
msgid "labels.sso-error.desc-message"
msgstr "Sign-in with your organization's identity provider didn't complete. The provider may be unavailable, or your account may not be in its directory yet. Your Penpot account isn't affected."
msgid "labels.sso-error.retry"
msgstr "Try again"

View File

@ -9989,3 +9989,12 @@ msgstr "Pulsar para cerrar la ruta"
msgid "notifications.invitation-canceled"
msgstr "Esta invitación ya no está disponible."
msgid "labels.sso-error.title"
msgstr "No pudimos iniciar sesión en tu organización"
msgid "labels.sso-error.desc-message"
msgstr "El inicio de sesión con el proveedor de identidad de tu organización no se completó. Es posible que el proveedor no esté disponible o que tu cuenta aún no esté en su directorio. Tu cuenta de Penpot no se ha visto afectada."
msgid "labels.sso-error.retry"
msgstr "Intentar de nuevo"