diff --git a/backend/src/app/auth/oidc.clj b/backend/src/app/auth/oidc.clj index ecf8b658a5..4eea4fbc4f 100644 --- a/backend/src/app/auth/oidc.clj +++ b/backend/src/app/auth/oidc.clj @@ -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 (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 @@ -854,9 +868,25 @@ :body (u/map->query-string params)} response (http/req cfg req {:skip-ssrf-check? (:skip-ssrf-check? provider)})] (cond - (token-endpoint-valid-client-error? response) true - (token-endpoint-invalid-client-error? response) false - :else false))) + (token-endpoint-valid-client-error? response) + true + + (token-endpoint-invalid-client-error? response) + (do + (l/warn :hint "organization SSO client credentials rejected by the token endpoint" + :token-uri (:token-uri provider) + :status (:status response) + :error (token-endpoint-error response) + :error-description (token-endpoint-error-description response)) + false) + + :else + (do + (l/warn :hint "unexpected token endpoint response while probing organization SSO client credentials" + :token-uri (:token-uri provider) + :status (:status response) + :body (:body response)) + false)))) (defn is-organization-sso-config-valid? "Return true when the SSO config can be discovered, can build a login URL, @@ -867,8 +897,15 @@ (let [provider (prepare-organization-sso-provider cfg sso)] (and (build-organization-sso-auth-redirect-uri cfg sso :provider provider) (probe-organization-sso-client-credentials cfg provider))) - false) - (catch Throwable _ false))) + (do + (l/warn :hint "organization SSO config is missing a valid issuer" + :issuer (:issuer sso)) + false)) + (catch Throwable cause + (l/warn :hint "organization SSO config validation failed" + :issuer (:issuer sso) + :cause cause) + false))) (defn- auth-handler [cfg {:keys [params] :as request}] @@ -899,17 +936,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) diff --git a/frontend/src/app/main/data/nitrate.cljs b/frontend/src/app/main/data/nitrate.cljs index ff0f48c3e2..dc6ce4615b 100644 --- a/frontend/src/app/main/data/nitrate.cljs +++ b/frontend/src/app/main/data/nitrate.cljs @@ -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; diff --git a/frontend/src/app/main/ui.cljs b/frontend/src/app/main/ui.cljs index 58175de604..096357e460 100644 --- a/frontend/src/app/main/ui.cljs +++ b/frontend/src/app/main/ui.cljs @@ -237,57 +237,65 @@ search-term (some-> params :search-term) plugin-url (some-> params :plugin) template (some-> params :template) - pending-action-id (some-> params :pending-action-id uuid/parse*)] - [:? - #_[:& app.main.ui.releases/release-notes-modal {:version "2.5"}] - #_[:& app.main.ui.onboarding/onboarding-templates-modal] - #_[:& app.main.ui.onboarding/onboarding-modal] - #_[:> app.main.ui.onboarding.team-choice/onboarding-team-modal*] + pending-action-id (some-> params :pending-action-id uuid/parse*) + sso-error? (some? (:sso-error params)) + organization-id (some-> params :organization-id uuid/parse*)] + (if sso-error? + [:> static/sso-error* {:organization-id organization-id :profile profile :is-dashboard true}] + [:? + #_[:& app.main.ui.releases/release-notes-modal {:version "2.5"}] + #_[:& app.main.ui.onboarding/onboarding-templates-modal] + #_[:& app.main.ui.onboarding/onboarding-modal] + #_[:> app.main.ui.onboarding.team-choice/onboarding-team-modal*] - (cond - show-question-modal? - [:& questions-modal] + (cond + show-question-modal? + [:& questions-modal] - show-team-modal? - [:> onboarding-team-modal* {:go-to-team true}] + show-team-modal? + [:> onboarding-team-modal* {:go-to-team true}] - show-release-modal? - [:& release-notes-modal {:version (:main cf/version)}]) + show-release-modal? + [:& release-notes-modal {:version (:main cf/version)}]) - [:> team-container* {:team-id team-id} - [:> dashboard-page* {:profile profile - :section section - :team-id team-id - :search-term search-term - :plugin-url plugin-url - :project-id project-id - :template template - :pending-action-id pending-action-id}]]]) + [:> team-container* {:team-id team-id} + [:> dashboard-page* {:profile profile + :section section + :team-id team-id + :search-term search-term + :plugin-url plugin-url + :project-id project-id + :template template + :pending-action-id pending-action-id}]]])) :workspace (let [params (get params :query) team-id (some-> params :team-id uuid/parse*) file-id (some-> params :file-id uuid/parse*) page-id (some-> params :page-id uuid/parse*) - layout (some-> params :layout keyword)] - [:? {} - (when (cf/external-feature-flag "onboarding-03" "test") - (cond - show-question-modal? - [:& questions-modal] + layout (some-> params :layout keyword) + sso-error? (some? (:sso-error params)) + organization-id (some-> params :organization-id uuid/parse*)] + (if sso-error? + [:> static/sso-error* {:organization-id organization-id :profile profile :is-workspace true}] + [:? {} + (when (cf/external-feature-flag "onboarding-03" "test") + (cond + show-question-modal? + [:& questions-modal] - show-team-modal? - [:> onboarding-team-modal* {:go-to-team false}] + show-team-modal? + [:> onboarding-team-modal* {:go-to-team false}] - show-release-modal? - [:& release-notes-modal {:version (:main cf/version)}])) + show-release-modal? + [:& release-notes-modal {:version (:main cf/version)}])) - [:> team-container* {:team-id team-id} - [:> workspace-page* {:team-id team-id - :file-id file-id - :page-id page-id - :layout-name layout - :key file-id}]]]) + [:> team-container* {:team-id team-id} + [:> workspace-page* {:team-id team-id + :file-id file-id + :page-id page-id + :layout-name layout + :key file-id}]]])) :viewer (let [params (get params :query) diff --git a/frontend/src/app/main/ui/routes.cljs b/frontend/src/app/main/ui/routes.cljs index 52d7554e1e..73502677c4 100644 --- a/frontend/src/app/main/ui/routes.cljs +++ b/frontend/src/app/main/ui/routes.cljs @@ -108,7 +108,8 @@ 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? + sso-error? (some? (get-in match [:query-params :sso-error])) + team-id-str (when (and relevant? (not sso-error?)) (or (get-in match [:query-params :team-id]) (get-in match [:params :path :team-id]))) team-id (some-> team-id-str uuid/parse*)] diff --git a/frontend/src/app/main/ui/static.cljs b/frontend/src/app/main/ui/static.cljs index 9e6dfa68f0..48b27d5684 100644 --- a/frontend/src/app/main/ui/static.cljs +++ b/frontend/src/app/main/ui/static.cljs @@ -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] @@ -515,6 +516,46 @@ children]) +(mf/defc sso-error* + "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 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 clean-url) + (fn [] + (st/emit! (dnt/retry-organization-sso {:organization-id organization-id + :dest-url 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-page* [{:keys [data route] :as props}] diff --git a/frontend/translations/en.po b/frontend/translations/en.po index b9621af9e4..c9dcc0f1dd 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -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" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 51165035e2..f0dc4675c8 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -9989,3 +9989,13 @@ 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"