|
12 | 12 | [ring.util.response :as response] |
13 | 13 | [selmer.parser :as selmer]) |
14 | 14 | (:import |
| 15 | + [java.io File] |
15 | 16 | [java.nio.charset StandardCharsets] |
16 | 17 | [java.security MessageDigest SecureRandom] |
17 | 18 | [java.util Base64] |
|
195 | 196 |
|
196 | 197 | (defn oauth-info |
197 | 198 | "Perform OAuth discovery for the given MCP server URL. |
198 | | - Optional `configured-client-id` skips dynamic client registration when provided." |
199 | | - ([^String url] (oauth-info url nil)) |
200 | | - ([^String url configured-client-id] |
| 199 | + Optional `configured-client-id` skips dynamic client registration when provided. |
| 200 | + Optional `configured-client-secret` enables confidential OAuth (e.g. Slack MCP). |
| 201 | + Optional `configured-oauth-port` uses a fixed port for the OAuth callback (required |
| 202 | + by providers like Slack that mandate pre-registered redirect URIs)." |
| 203 | + ([^String url] (oauth-info url nil nil)) |
| 204 | + ([^String url configured-client-id] (oauth-info url configured-client-id nil)) |
| 205 | + ([^String url configured-client-id configured-client-secret] |
| 206 | + (oauth-info url configured-client-id configured-client-secret nil)) |
| 207 | + ([^String url configured-client-id configured-client-secret configured-oauth-port] |
201 | 208 | (let [base-url (url->base-url url) |
202 | 209 | auth-response (probe-auth url)] |
203 | 210 | (when-let [headers (:headers auth-response)] |
204 | | - (let [callback-port (get-free-port) |
205 | | - redirect-uri (format "http://localhost:%s/auth/callback" callback-port) |
| 211 | + (let [ssl? (some? configured-oauth-port) |
| 212 | + callback-port (if configured-oauth-port |
| 213 | + (int configured-oauth-port) |
| 214 | + (get-free-port)) |
| 215 | + scheme (if ssl? "https" "http") |
| 216 | + redirect-uri (format "%s://localhost:%s/auth/callback" scheme callback-port) |
206 | 217 | www-authenticate (some-> (get headers "www-authenticate") parse-www-authenticate) |
207 | 218 | ;; Step 1: Discover resource/auth metadata (PRM per RFC 9728, then legacy fallback) |
208 | 219 | first-meta (discover-resource-metadata www-authenticate base-url) |
|
219 | 230 | ;; Skip DCR when a client-id is pre-configured |
220 | 231 | new-client-id (when-not configured-client-id |
221 | 232 | (when-let [reg-endpoint (:registration_endpoint meta)] |
222 | | - (let [res (http/post |
| 233 | + (let [auth-method (if configured-client-secret |
| 234 | + "client_secret_post" |
| 235 | + "none") |
| 236 | + res (http/post |
223 | 237 | reg-endpoint |
224 | 238 | {:timeout 10000 |
225 | 239 | :as :json |
226 | 240 | :throw-exceptions? false |
227 | 241 | :headers {"Content-Type" "application/json"} |
228 | 242 | :body (json/generate-string |
229 | 243 | {:redirect_uris [redirect-uri] |
230 | | - :token_endpoint_auth_method "none" |
| 244 | + :token_endpoint_auth_method auth-method |
231 | 245 | :grant_types ["authorization_code" "refresh_token"] |
232 | 246 | :response_types ["code"] |
233 | 247 | :client_name "ECA (Editor Code Assistant)" |
|
251 | 265 | :redirect_uri redirect-uri |
252 | 266 | :resource url} |
253 | 267 | scope (assoc :scope scope)))] |
254 | | - {:callback-port callback-port |
255 | | - :token-endpoint (or (:token_endpoint meta) |
256 | | - (str auth-server "/access_token")) |
257 | | - :verifier verifier |
258 | | - :client-id client-id |
259 | | - :redirect-uri redirect-uri |
260 | | - :resource url |
261 | | - :authorization-endpoint (str base-auth-endpoint "?" query-params)}))))))) |
| 268 | + (cond-> {:callback-port callback-port |
| 269 | + :token-endpoint (or (:token_endpoint meta) |
| 270 | + (str auth-server "/access_token")) |
| 271 | + :verifier verifier |
| 272 | + :client-id client-id |
| 273 | + :redirect-uri redirect-uri |
| 274 | + :resource url |
| 275 | + :authorization-endpoint (str base-auth-endpoint "?" query-params)} |
| 276 | + ssl? (assoc :ssl? true) |
| 277 | + configured-client-secret (assoc :client-secret configured-client-secret))))))))) |
262 | 278 |
|
263 | 279 | (comment |
264 | 280 | (oauth-info "https://mcp.atlassian.com/v1/sse") |
265 | 281 | (oauth-info "https://mcp.miro.com/") |
266 | 282 | (oauth-info "https://api.githubcopilot.com/mcp/")) |
267 | 283 |
|
| 284 | +(defn ^:private extract-keystore-to-temp |
| 285 | + "Extract the bundled localhost PKCS12 keystore to a temp file. |
| 286 | + Returns the absolute path to the temp file." |
| 287 | + ^String [] |
| 288 | + (let [res (io/resource "tls/localhost.p12")] |
| 289 | + (when-not res |
| 290 | + (throw (ex-info "Bundled localhost keystore not found on classpath" |
| 291 | + {:resource "tls/localhost.p12"}))) |
| 292 | + (let [temp-file (File/createTempFile "eca-oauth-" ".p12")] |
| 293 | + (.deleteOnExit temp-file) |
| 294 | + (with-open [in (io/input-stream res)] |
| 295 | + (io/copy in temp-file)) |
| 296 | + (.getAbsolutePath temp-file)))) |
| 297 | + |
268 | 298 | (defn start-oauth-server! |
269 | | - "Start local server on port to handle OAuth redirect" |
270 | | - [{:keys [on-error on-success port]}] |
| 299 | + "Start local server on port to handle OAuth redirect. |
| 300 | + When :ssl? is true, starts HTTPS using the bundled localhost certificate." |
| 301 | + [{:keys [on-error on-success port ssl?]}] |
271 | 302 | (when-not (get @oauth-server-by-port* port) |
272 | | - (let [handler (-> oauth-handler |
273 | | - wrap-keyword-params |
274 | | - wrap-params) |
275 | | - server (jetty/run-jetty |
276 | | - (fn [request] |
277 | | - (if (= "/auth/callback" (:uri request)) |
278 | | - (handler request on-success on-error) |
279 | | - (-> (response/response "404 Not Found") |
280 | | - (response/status 404)))) |
281 | | - {:port (or port (get-free-port)) |
282 | | - :join? false})] |
283 | | - (swap! oauth-server-by-port* assoc port server) |
284 | | - (logger/info logger-tag (str "OAuth server started on http://localhost:" port)) |
285 | | - {:server server |
286 | | - :port port}))) |
| 303 | + (try |
| 304 | + (let [handler (-> oauth-handler |
| 305 | + wrap-keyword-params |
| 306 | + wrap-params) |
| 307 | + request-handler (fn [request] |
| 308 | + (if (= "/auth/callback" (:uri request)) |
| 309 | + (handler request on-success on-error) |
| 310 | + (-> (response/response "404 Not Found") |
| 311 | + (response/status 404)))) |
| 312 | + jetty-opts (if ssl? |
| 313 | + {:ssl? true |
| 314 | + :ssl-port (or port (get-free-port)) |
| 315 | + :http? false |
| 316 | + :keystore (extract-keystore-to-temp) |
| 317 | + :key-password "ecalocal" |
| 318 | + :keystore-type "PKCS12" |
| 319 | + :join? false} |
| 320 | + {:port (or port (get-free-port)) |
| 321 | + :join? false}) |
| 322 | + server (jetty/run-jetty request-handler jetty-opts) |
| 323 | + scheme (if ssl? "https" "http")] |
| 324 | + (swap! oauth-server-by-port* assoc port server) |
| 325 | + (logger/info logger-tag (str "OAuth server started on " scheme "://localhost:" port)) |
| 326 | + {:server server |
| 327 | + :port port}) |
| 328 | + (catch java.net.BindException _ |
| 329 | + (logger/error logger-tag (format "Port %d is already in use — is another ECA instance running?" port)) |
| 330 | + nil)))) |
287 | 331 |
|
288 | 332 | (defn stop-oauth-server! |
289 | 333 | "Stop the local OAuth server" |
290 | 334 | [port] |
291 | | - (when-let [^Server server (get oauth-server-by-port* port)] |
| 335 | + (when-let [^Server server (get @oauth-server-by-port* port)] |
292 | 336 | (.stop server) |
293 | 337 | (swap! oauth-server-by-port* dissoc port) |
294 | 338 | (logger/info logger-tag "OAuth server stopped"))) |
|
317 | 361 | ;; Fallback to raw error |
318 | 362 | {:raw_error body-str}))) |
319 | 363 |
|
320 | | -(defn authorize-token! [{:keys [token-endpoint verifier client-id redirect-uri resource]} code] |
| 364 | +(defn authorize-token! [{:keys [token-endpoint verifier client-id client-secret redirect-uri resource]} code] |
321 | 365 | (let [{:keys [status body]} (http/post |
322 | 366 | token-endpoint |
323 | 367 | {:headers {"Content-Type" "application/x-www-form-urlencoded" |
|
328 | 372 | :code code |
329 | 373 | :code_verifier verifier |
330 | 374 | :redirect_uri redirect-uri} |
| 375 | + client-secret (assoc :client_secret client-secret) |
331 | 376 | resource (assoc :resource resource))) |
332 | 377 | :throw-exceptions? false |
333 | 378 | :as :stream}) |
|
367 | 412 | (defn refresh-token! |
368 | 413 | "Refresh an OAuth access token using a refresh token. |
369 | 414 | Returns {:access-token :refresh-token :expires-at} on success, nil if refresh fails." |
370 | | - [token-endpoint client-id refresh-token & {:keys [resource]}] |
| 415 | + [token-endpoint client-id refresh-token & {:keys [client-secret resource]}] |
371 | 416 | (try |
372 | 417 | (let [{:keys [status body]} (http/post |
373 | 418 | token-endpoint |
|
377 | 422 | (cond-> {:grant_type "refresh_token" |
378 | 423 | :client_id client-id |
379 | 424 | :refresh_token refresh-token} |
| 425 | + client-secret (assoc :client_secret client-secret) |
380 | 426 | resource (assoc :resource resource))) |
381 | 427 | :throw-exceptions? false |
382 | 428 | :as :stream}) |
|
0 commit comments