mirror of
https://github.com/penpot/penpot.git
synced 2026-11-04 17:10:39 +00:00
* 🎉 Add OAuth support for remote MCP connections Add MCP-specific discovery, client registration, and approval endpoints. Exchange single-use authorization codes with PKCE for the fixed MCP user token, enabling MCP and creating the token on approval when needed. Use self-contained client IDs without new database tables or stored client records. Keep temporary authorization state in Redis/Valkey. Accept bearer credentials while preserving query-parameter connections. Enable OAuth through PENPOT_MCP_AUTH_URI in multi-user mode. Document the environment variable in the README and implementation details in Serena. Explain protocol and concurrency decisions in the Clojure code. Cover token provisioning, OAuth state, client registration, HTTP flows, and the approval page with tests. AI-assisted-by: gpt-6 * 🐛 Wait for devenv initialization before starting builds Docker reports a container as Running as soon as its main process starts, while the entrypoint may still be preparing the development environment. manage.sh used that status to launch tmux jobs, allowing frontend builds to race with ownership changes to /opt/rustup and Cargo setup. On a fresh container, rustup could fail with Permission denied before the frontend watcher started. Nginx would later serve the existing JavaScript, leaving source changes absent from the running app despite restarting devenv. Wait for nginx's local health endpoint before launching the jobs. Nginx starts after entrypoint setup, so its response confirms that setup has finished. Allow up to 120 seconds and report a timeout without starting the jobs if initialization does not complete. Validation: shell syntax and whitespace checks pass. AI-assisted-by: gpt-6 * ♻️ Share the login modal with MCP authorization Extract the viewer login modal and its styles into a shared auth component, retaining the viewer wrapper and its close behavior. Use the modal on the MCP approval page and preserve the pending request through login. Closing it clears the saved return URL and shows a cancellation message without further actions. Cover return URL handling and cancellation with frontend tests. AI-assisted-by: gpt-6 * ✨ Show client icons in MCP authorization Preserve HTTPS logo URLs from client registration and published metadata and show the icon beside the application name on the approval page. Ignore unusable URLs and hide failed images without blocking approval. Update the access description to refer to MCP connections established from Penpot. Cover logo metadata and approval responses in backend tests. AI-assisted-by: gpt-6 * 🐛 Follow the system theme when no preference is configured Default to the system theme in the shared resolver used by the app and plugins. Preserve explicit light and dark choices, including the legacy default value that means dark. Reuse the reactive theme hook in progress widgets and show the system preference in settings for accounts without a configured theme. Verify preference resolution with regression tests and browser checks. All 1,474 frontend tests pass. AI-assisted-by: gpt-6 * 🐛 Replace expired MCP tokens during OAuth approval Settings can give an MCP token an expiry date. OAuth approval reused the newest MCP token without checking it, so an expired token reached the client and never matched a plugin connection. Approval now treats an expired token as missing and creates a new one. Token exchange rejects a code whose token expired after approval with invalid_grant. AI-assisted-by: claude-opus-5-5 * 🐛 Log unexpected errors on MCP OAuth endpoints The OAuth routes sit outside the shared HTTP middleware, so the shared error handler never saw them, and their own handler turned unexpected failures into a 503 without logging anything. Failures in the session middleware of the approval endpoints had no handler at all. Unexpected failures now also go through the shared error handler for its logging, while clients still get an OAuth error body. The OAuth error handler also wraps the session middleware of the approval endpoints. AI-assisted-by: claude-opus-5-5 * 🔧 Add devenv script to run the MCP server with OAuth start-mcp-devenv-oauth starts the MCP server in multi-user mode with PENPOT_MCP_AUTH_URI derived from PENPOT_PUBLIC_URI, to test OAuth in the devenv. Agentic tooling still uses the single-user start-mcp-devenv. AI-assisted-by: claude-opus-5-5 * 🐛 Fix failing test case --------- Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
287 lines
9.6 KiB
Clojure
287 lines
9.6 KiB
Clojure
;; 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 SUBSIDIARY SL
|
|
|
|
(ns app.http
|
|
(:require
|
|
[app.auth.oidc :as-alias oidc]
|
|
[app.common.data :as d]
|
|
[app.common.logging :as l]
|
|
[app.common.schema :as sm]
|
|
[app.common.transit :as t]
|
|
[app.db :as-alias db]
|
|
[app.http.access-token :as actoken]
|
|
[app.http.assets :as-alias assets]
|
|
[app.http.awsns :as-alias awsns]
|
|
[app.http.debug :as-alias debug]
|
|
[app.http.errors :as errors]
|
|
[app.http.link-preview :as-alias link-preview]
|
|
[app.http.management :as mgmt]
|
|
[app.http.mcp-oauth :as-alias mcp-oauth]
|
|
[app.http.middleware :as mw]
|
|
[app.http.security :as sec]
|
|
[app.http.session :as session]
|
|
[app.http.websocket :as-alias ws]
|
|
[app.main :as-alias main]
|
|
[app.metrics :as mtx]
|
|
[app.rpc :as-alias rpc]
|
|
[app.setup :as-alias setup]
|
|
[integrant.core :as ig]
|
|
[promesa.exec :as px]
|
|
[reitit.core :as r]
|
|
[reitit.middleware :as rr]
|
|
[yetti.adapter :as yt]
|
|
[yetti.request :as yreq]
|
|
[yetti.response :as-alias yres])
|
|
(:import
|
|
io.undertow.server.ConnectorStatistics
|
|
io.undertow.Undertow
|
|
org.xnio.management.XnioWorkerMXBean))
|
|
|
|
(declare router-handler)
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; HTTP SERVER
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(def default-params
|
|
{::port 6060
|
|
::host "0.0.0.0"
|
|
::max-body-size 367001600 ; default 350 MiB
|
|
})
|
|
|
|
(def ^:private metrics-sample-interval-ms 15000)
|
|
|
|
(defn sample-worker-metrics
|
|
"Publishes the current state of the xnio worker thread pool (the
|
|
request dispatch queue and its threads) as gauges. The pool size is the
|
|
signal to watch: the busy thread count of this MXBean stays at zero with
|
|
threads blocked inside a concurrency limiter, so it is not published."
|
|
[metrics ^XnioWorkerMXBean mxbean]
|
|
(let [queue-size (.getWorkerQueueSize mxbean)
|
|
pool-size (.getWorkerPoolSize mxbean)
|
|
max-size (.getMaxWorkerPoolSize mxbean)]
|
|
|
|
;; negative values are missing measurements, not zeros: the xnio MXBean
|
|
;; may transiently report -1.
|
|
(when (>= queue-size 0)
|
|
(mtx/run! metrics :id :http-worker-queue-size :val queue-size))
|
|
|
|
(when (>= pool-size 0)
|
|
(mtx/run! metrics :id :http-worker-pool-size :val pool-size))
|
|
|
|
(when (>= max-size 0)
|
|
(mtx/run! metrics :id :http-worker-max-pool-size :val max-size))))
|
|
|
|
(defn sample-connector-metrics
|
|
"Publishes the current state of the http listener connection
|
|
statistics. Undertow exposes absolute totals, so counters are
|
|
published as deltas of the last seen values (the atom state holds the
|
|
last observed totals). When a delta comes back negative (mainly
|
|
because the underlying counters were reset) the counter is skipped
|
|
and the reference updated."
|
|
[metrics state ^ConnectorStatistics cs]
|
|
(let [{:keys [last-requests last-errors]} (deref state)
|
|
total-requests (.getRequestCount cs)
|
|
total-errors (.getErrorCount cs)
|
|
delta-requests (max 0 (- total-requests last-requests))
|
|
delta-errors (max 0 (- total-errors last-errors))]
|
|
|
|
(when (pos? delta-requests)
|
|
(mtx/run! metrics :id :http-connector-requests-total :inc delta-requests))
|
|
|
|
(when (pos? delta-errors)
|
|
(mtx/run! metrics :id :http-connector-errors-total :inc delta-errors))
|
|
|
|
(mtx/run! metrics
|
|
:id :http-connector-active-connections
|
|
:val (.getActiveConnections cs))
|
|
|
|
(swap! state merge {:last-requests total-requests
|
|
:last-errors total-errors})))
|
|
|
|
(defn sample-http-metrics
|
|
"Samples the current state of the http server: worker thread pool
|
|
state and listener connection statistics. Called periodically by a
|
|
sampler that starts together with the server."
|
|
[metrics state ^Undertow server]
|
|
(try
|
|
(when-let [mxbean (some-> server (.getWorker) (.getMXBean))]
|
|
(sample-worker-metrics metrics mxbean))
|
|
|
|
(when-let [cs (some-> server (.getListenerInfo) (first) (.getConnectorStatistics))]
|
|
(sample-connector-metrics metrics state cs))
|
|
|
|
(catch Exception cause
|
|
(l/warn :msg "unexpected error on http metrics sampling"
|
|
:cause cause))))
|
|
|
|
(defn create-metrics-sampler
|
|
"Creates a daemon scheduler that periodically samples the state of
|
|
the http server and publishes it as metrics, in the shape shared by
|
|
`app.metrics/create-sampler`: a single thread, and an unexpected error
|
|
on one sample does not cancel the following runs."
|
|
[^Undertow server metrics]
|
|
(let [state (atom {:last-requests 0 :last-errors 0})]
|
|
(mtx/create-sampler metrics-sample-interval-ms
|
|
(fn [] (sample-http-metrics metrics state server))
|
|
{:name "http-metrics"})))
|
|
|
|
(defmethod ig/expand-key ::server
|
|
[k v]
|
|
{k (merge default-params (d/without-nils v))})
|
|
|
|
(def ^:private schema:server-params
|
|
[:map
|
|
[::port ::sm/int]
|
|
[::host ::sm/text]
|
|
[::io-threads {:optional true} ::sm/int]
|
|
[::max-worker-threads {:optional true} ::sm/int]
|
|
[::max-body-size {:optional true} ::sm/int]
|
|
[::router {:optional true} [:fn r/router?]]
|
|
[::handler {:optional true} ::sm/fn]])
|
|
|
|
(defmethod ig/assert-key ::server
|
|
[_ params]
|
|
(assert (sm/check schema:server-params params)))
|
|
|
|
(defmethod ig/init-key ::server
|
|
[_ {:keys [::handler ::router ::host ::port ::mtx/metrics] :as cfg}]
|
|
(l/info :hint "starting http server" :port port :host host)
|
|
(let [on-dispatch
|
|
(fn [_ start-at-ns]
|
|
(let [timing (- (System/nanoTime) start-at-ns)
|
|
timing (int (/ timing 1000000))]
|
|
(mtx/run! metrics
|
|
:id :http-server-dispatch-timing
|
|
:val timing)))
|
|
|
|
options
|
|
{:http/port port
|
|
:http/host host
|
|
:http/max-body-size (::max-body-size cfg)
|
|
:http/max-multipart-body-size (::max-body-size cfg)
|
|
:xnio/direct-buffers false
|
|
:xnio/io-threads (::io-threads cfg)
|
|
:xnio/max-worker-threads (::max-worker-threads cfg)
|
|
:ring/compat :ring2
|
|
:server/statistics true
|
|
:events/on-dispatch on-dispatch
|
|
:socket/backlog 4069}
|
|
|
|
handler
|
|
(cond
|
|
(some? router)
|
|
(router-handler router)
|
|
|
|
(some? handler)
|
|
handler
|
|
|
|
:else
|
|
(throw (UnsupportedOperationException. "handler or router are required")))
|
|
|
|
server
|
|
(yt/start! (yt/server handler (d/without-nils options)))
|
|
|
|
sampler
|
|
(create-metrics-sampler server metrics)]
|
|
|
|
(assoc cfg ::server server ::metrics-sampler sampler)))
|
|
|
|
(defmethod ig/halt-key! ::server
|
|
[_ {:keys [::metrics-sampler ::server ::port] :as cfg}]
|
|
(l/info :msg "stopping http server" :port port)
|
|
(px/shutdown-now metrics-sampler)
|
|
(yt/stop! server))
|
|
|
|
(defn- not-found-handler
|
|
[_]
|
|
{::yres/status 404})
|
|
|
|
(defn- router-handler
|
|
[router]
|
|
(letfn [(resolve-handler [request]
|
|
(if-let [match (r/match-by-path router (yreq/path request))]
|
|
(let [params (:path-params match)
|
|
result (:result match)
|
|
handler (or (:handler result) not-found-handler)
|
|
request (assoc request :path-params params)]
|
|
(partial handler request))
|
|
(partial not-found-handler request)))
|
|
|
|
(on-error [cause request]
|
|
(let [{:keys [::yres/body] :as response} (errors/handle cause request)]
|
|
(cond-> response
|
|
(map? body)
|
|
(-> (update ::yres/headers assoc "content-type" "application/transit+json")
|
|
(assoc ::yres/body (t/encode-str body {:type :json-verbose}))))))]
|
|
|
|
(fn [request]
|
|
(let [handler (resolve-handler request)]
|
|
(try
|
|
(handler)
|
|
(catch Throwable cause
|
|
(on-error cause request)))))))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; HTTP ROUTER
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(def ^:private schema:routes
|
|
[:vector :any])
|
|
|
|
(def ^:private schema:router-params
|
|
[:map
|
|
[::ws/routes schema:routes]
|
|
[::rpc/routes schema:routes]
|
|
[::oidc/routes schema:routes]
|
|
[::mcp-oauth/routes schema:routes]
|
|
[::assets/routes schema:routes]
|
|
[::link-preview/routes schema:routes]
|
|
[::debug/routes schema:routes]
|
|
[::mtx/routes schema:routes]
|
|
[::awsns/routes schema:routes]
|
|
[::mgmt/routes schema:routes]
|
|
::session/manager
|
|
::setup/props
|
|
::mtx/metrics
|
|
::db/pool])
|
|
|
|
(defmethod ig/assert-key ::router
|
|
[_ params]
|
|
(assert (sm/check schema:router-params params)))
|
|
|
|
(defmethod ig/init-key ::router
|
|
[_ cfg]
|
|
(rr/router
|
|
[["" {:middleware [[mw/server-timing]
|
|
[sec/sec-fetch-metadata]
|
|
[mw/params]
|
|
[mw/format-response]
|
|
[mw/auth {:bearer (partial session/decode-token cfg)
|
|
:cookie (partial session/decode-token cfg)
|
|
:token (partial actoken/decode-token cfg)}]
|
|
[mw/parse-request (::mtx/metrics cfg)]
|
|
[mw/errors errors/handle]
|
|
[mw/restrict-methods]]}
|
|
|
|
(::mtx/routes cfg)
|
|
(::assets/routes cfg)
|
|
(::link-preview/routes cfg)
|
|
(::debug/routes cfg)
|
|
|
|
["/webhooks"
|
|
(::awsns/routes cfg)]
|
|
|
|
["/management"
|
|
(::mgmt/routes cfg)]
|
|
|
|
(::ws/routes cfg)
|
|
(::oidc/routes cfg)
|
|
(::rpc/routes cfg)]
|
|
;; OAuth owns form parsing, protocol JSON names and cross-origin handling;
|
|
;; it must not inherit the RPC transformations and session middleware above.
|
|
(::mcp-oauth/routes cfg)]))
|