mz_environmentd/http/oauth_metadata.rs
1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! OAuth 2.0 Protected Resource Metadata for the MCP endpoints.
11//!
12//! Implements [RFC 9728] so MCP-aware clients (e.g. Claude Desktop's Custom
13//! Connectors, ChatGPT remote MCP) can discover the authorization server
14//! they need to authenticate against in order to call our MCP endpoints.
15//!
16//! The discovery document is published at
17//! `/.well-known/oauth-protected-resource` and is **public** (no auth).
18//! Clients reach it either by following the `resource_metadata` parameter
19//! emitted in a `WWW-Authenticate` challenge from a 401 on `/api/mcp/*`
20//! (see [`crate::http::AuthError`]), or by probing the well-known URI
21//! directly.
22//!
23//! ## Host derivation
24//!
25//! The `resource` field and the `resource_metadata` URL embedded in the
26//! 401 challenge are both absolute URLs and therefore depend on knowing
27//! the externally-visible host of this environment. We resolve that in
28//! this order:
29//!
30//! 1. `HttpConfig::http_host_name` (set by the operator).
31//! 2. The request's `Host` header.
32//!
33//! We deliberately do **not** consult `X-Forwarded-Host` or
34//! `X-Forwarded-Proto`: environmentd has no proxy-trust configuration
35//! today, and trusting those headers blind would let any client reaching
36//! the server directly poison the published metadata URLs (a host-header
37//! injection on the OAuth flow). Deployments behind a load balancer that
38//! rewrites `Host` are expected to set `http_host_name` explicitly.
39//!
40//! ## When the document is published
41//!
42//! The handler returns 404 when no OAuth flow is meaningful for the
43//! listener: either because `oidc_issuer` is unset (no authorization
44//! server to advertise) or because the listener's authenticator is
45//! [`listeners::AuthenticatorKind::None`] (no token would ever be
46//! validated). Returning an empty or fake document instead would mislead
47//! clients into starting an OAuth dance they cannot complete.
48//!
49//! [RFC 9728]: https://datatracker.ietf.org/doc/html/rfc9728
50
51use std::sync::Arc;
52use std::time::Duration;
53
54use axum::Extension;
55use axum::extract::Request;
56use axum::response::{IntoResponse, Response};
57use http::{HeaderValue, StatusCode};
58use mz_adapter::Client;
59use mz_adapter_types::dyncfgs::{OIDC_AUDIENCE, OIDC_ISSUER};
60use mz_ore::metric;
61use mz_ore::metrics::MetricsRegistry;
62use mz_server_core::listeners;
63use prometheus::IntCounterVec;
64use serde::Serialize;
65use tracing::warn;
66use url::Url;
67
68use crate::http::Delayed;
69
70/// Bounds how long the unauthenticated discovery handler waits for the
71/// adapter client. Without this, a wedged adapter could pin connections
72/// indefinitely from any caller on the network.
73const ADAPTER_WAIT_TIMEOUT: Duration = Duration::from_secs(2);
74
75/// OAuth 2.1 §3.1 requires `https` for all OAuth endpoints.
76const PUBLISHED_SCHEME: &str = "https";
77
78/// The well-known path served by this module.
79///
80/// Per [RFC 9728 §3] this is the OAuth 2.0 Protected Resource Metadata
81/// well-known URI; MCP clients probe it as a fallback when no
82/// `resource_metadata` parameter is present in a 401's `WWW-Authenticate`.
83///
84/// [RFC 9728 §3]: https://datatracker.ietf.org/doc/html/rfc9728#section-3
85pub(crate) const PROTECTED_RESOURCE_METADATA_PATH: &str = "/.well-known/oauth-protected-resource";
86
87/// Path-suffixed aliases of [`PROTECTED_RESOURCE_METADATA_PATH`] per
88/// RFC 9728 §3.1. Both MCP endpoints serve an identical metadata
89/// document today, so the same handler is mounted at all three paths;
90/// the aliases exist so strict clients that always probe with a path
91/// suffix do not have to fall back to the bare URI.
92pub(crate) const PROTECTED_RESOURCE_METADATA_PATH_AGENT: &str =
93 "/.well-known/oauth-protected-resource/api/mcp/agent";
94pub(crate) const PROTECTED_RESOURCE_METADATA_PATH_DEVELOPER: &str =
95 "/.well-known/oauth-protected-resource/api/mcp/developer";
96
97/// OAuth scope advertised for the MCP endpoints. Not enforced
98/// server-side (authorization is at the SQL layer via RBAC).
99pub(crate) const MCP_SCOPE: &str = "mcp.read";
100
101/// `private` (not the RFC-default `public`): the document varies by host,
102/// so shared caches must not serve one listener's document to another.
103const METADATA_CACHE_CONTROL: &str = "private, max-age=3600";
104
105/// JSON shape returned by [`PROTECTED_RESOURCE_METADATA_PATH`]. A
106/// strict subset of [RFC 9728 §2]; further fields can be added without
107/// breaking clients per the RFC's extensibility guidance.
108///
109/// [RFC 9728 §2]: https://datatracker.ietf.org/doc/html/rfc9728#section-2
110#[derive(Debug, Serialize, PartialEq, Eq)]
111pub(crate) struct ProtectedResourceMetadata {
112 /// Canonical URL of this resource server. Clients use this as the
113 /// `resource` parameter in their token request (RFC 8707).
114 pub resource: String,
115 /// Authorization servers a client may use to obtain a token. Each
116 /// entry is an issuer URL whose metadata document is fetchable at
117 /// `<issuer>/.well-known/oauth-authorization-server` (RFC 8414)
118 /// or `<issuer>/.well-known/openid-configuration` (OIDC Discovery).
119 pub authorization_servers: Vec<String>,
120 /// Mechanisms by which the client may present the bearer token.
121 /// We accept the `Authorization` header only.
122 pub bearer_methods_supported: Vec<String>,
123 /// Scopes a client may request a token for; see [`MCP_SCOPE`].
124 pub scopes_supported: Vec<String>,
125}
126
127/// Closed set of outcomes recorded by [`OauthMetadataMetrics`]. Keeping
128/// these as an enum (rather than free-form strings at the call sites) pins
129/// the metric's label cardinality and stops typos from silently creating
130/// new label values.
131#[derive(Debug, Clone, Copy)]
132enum MetricStatus {
133 Ok,
134 Disabled,
135 NoIssuer,
136 InvalidIssuer,
137 NoHost,
138 AdapterUnavailable,
139}
140
141impl MetricStatus {
142 fn as_str(self) -> &'static str {
143 match self {
144 Self::Ok => "ok",
145 Self::Disabled => "disabled",
146 Self::NoIssuer => "no_issuer",
147 Self::InvalidIssuer => "invalid_issuer",
148 Self::NoHost => "no_host",
149 Self::AdapterUnavailable => "adapter_unavailable",
150 }
151 }
152}
153
154/// Prometheus counter for the discovery endpoint, labeled by the
155/// [`MetricStatus`] of each request so dashboards key off names rather
156/// than HTTP status codes.
157#[derive(Debug, Clone)]
158pub struct OauthMetadataMetrics {
159 requests: IntCounterVec,
160}
161
162impl OauthMetadataMetrics {
163 pub fn register_into(registry: &MetricsRegistry) -> Self {
164 Self {
165 requests: registry.register(metric!(
166 name: "mz_oauth_protected_resource_metadata_requests_total",
167 help: "Total number of requests to the OAuth Protected Resource Metadata endpoint.",
168 var_labels: ["status"],
169 )),
170 }
171 }
172
173 fn inc(&self, status: MetricStatus) {
174 self.requests.with_label_values(&[status.as_str()]).inc();
175 }
176}
177
178/// Whether and how an HTTP listener advertises OAuth 2.0 for its MCP
179/// routes. Derived once per listener from its authenticator and consulted
180/// by both the 401 `WWW-Authenticate` challenge and this discovery handler,
181/// so the two never disagree about whether OAuth is on offer.
182#[derive(Debug, Clone, PartialEq, Eq)]
183pub(crate) enum McpOAuthDiscovery {
184 /// The listener validates no OAuth bearer token, so there is no flow to
185 /// advertise. Covers `None`, `Password`, and `Sasl`, plus `Frontegg`
186 /// when no issuer URL is configured.
187 Disabled,
188 /// Self-managed OIDC: the authorization server is the `oidc_issuer`
189 /// dyncfg, read at request time so an operator can change it without a
190 /// restart.
191 Oidc,
192 /// Frontegg (cloud): the authorization server is the workspace URL
193 /// supplied at startup via `--frontegg-oauth-issuer-url`.
194 Frontegg { issuer: String },
195}
196
197impl McpOAuthDiscovery {
198 /// The single mapping from a listener's authenticator to its OAuth
199 /// advertisement behavior. Frontegg requires a valid issuer URL;
200 /// otherwise falls back to `Disabled`.
201 pub(crate) fn for_authenticator(
202 kind: listeners::AuthenticatorKind,
203 frontegg_oauth_issuer_url: Option<&str>,
204 ) -> Self {
205 match (kind, frontegg_oauth_issuer_url) {
206 (listeners::AuthenticatorKind::Oidc, _) => Self::Oidc,
207 (listeners::AuthenticatorKind::Frontegg, Some(issuer)) => {
208 if let Err(err) = validate_issuer_url(issuer) {
209 warn!(
210 error = %err,
211 "frontegg-oauth-issuer-url is invalid; MCP OAuth discovery disabled"
212 );
213 Self::Disabled
214 } else {
215 Self::Frontegg {
216 issuer: issuer.to_string(),
217 }
218 }
219 }
220 _ => Self::Disabled,
221 }
222 }
223
224 pub(crate) fn is_enabled(&self) -> bool {
225 !matches!(self, Self::Disabled)
226 }
227}
228
229/// Per-listener config shared by the OAuth discovery handler and the auth
230/// middleware's `WWW-Authenticate` builder. Carried as an axum `Extension`
231/// because one environmentd process can serve listeners with different
232/// authenticators and `http_host_name`s.
233///
234/// Wrapping both consumers in one struct keeps them in lockstep: the 401
235/// challenge and the discovery document are computed from the same
236/// `discovery` value and the same `http_host_name`, so a client that
237/// follows the `resource_metadata` URL from a 401 reaches a document with
238/// matching host and matching authorization server.
239#[derive(Debug, Clone)]
240pub(crate) struct McpOAuthConfig {
241 /// Operator-configured external host (without scheme). Beats the
242 /// `Host` header when set.
243 pub http_host_name: Option<String>,
244 /// How (or whether) this listener advertises OAuth.
245 pub discovery: Arc<McpOAuthDiscovery>,
246}
247
248impl McpOAuthConfig {
249 /// OAuth scope advertised in the `Bearer` challenge's `scope=`
250 /// parameter on 401 responses. Constant today; method-form keeps the
251 /// auth middleware free of [`MCP_SCOPE`] knowledge.
252 pub(crate) fn scope(&self) -> &'static str {
253 MCP_SCOPE
254 }
255}
256
257/// HTTP handler for [`PROTECTED_RESOURCE_METADATA_PATH`].
258///
259/// Always public: no authentication is performed, by design. RFC 9728
260/// places no auth requirements on this endpoint; clients must be able to
261/// fetch it before they have a token.
262///
263/// Returns 404 when this listener does not advertise OAuth (see
264/// [`McpOAuthDiscovery`]) or has no issuer configured, 503 if the adapter
265/// client is not yet available (a brief window at startup), 400 if the
266/// request has no host information to construct a URL with, and 200 with
267/// the JSON document otherwise.
268pub(crate) async fn handle_protected_resource_metadata(
269 Extension(adapter_client_rx): Extension<Delayed<Client>>,
270 Extension(config): Extension<McpOAuthConfig>,
271 Extension(metrics): Extension<OauthMetadataMetrics>,
272 req: Request,
273) -> Response {
274 // Early-return for listeners that don't advertise OAuth, before we touch
275 // request headers. Keeps the response 404 even when `Host` is malformed,
276 // so probes can't distinguish "Disabled listener" from "non-existent
277 // listener" by the status code.
278 if !config.discovery.is_enabled() {
279 metrics.inc(MetricStatus::Disabled);
280 return StatusCode::NOT_FOUND.into_response();
281 }
282
283 let Some(host) = resolve_host(&req, config.http_host_name.as_deref()) else {
284 warn!(
285 "oauth-protected-resource: no http_host_name configured and request has no Host header"
286 );
287 metrics.inc(MetricStatus::NoHost);
288 return (StatusCode::BAD_REQUEST, "no host available").into_response();
289 };
290 let resource = format!("{PUBLISHED_SCHEME}://{host}/api/mcp");
291
292 let issuer = match &*config.discovery {
293 McpOAuthDiscovery::Disabled => unreachable!("handled above"),
294 McpOAuthDiscovery::Frontegg { issuer } => {
295 // Validated once at startup in `for_authenticator`; trusted here.
296 issuer.clone()
297 }
298 McpOAuthDiscovery::Oidc => {
299 let adapter_client =
300 match tokio::time::timeout(ADAPTER_WAIT_TIMEOUT, adapter_client_rx.clone()).await {
301 Ok(Ok(client)) => client,
302 Ok(Err(_)) | Err(_) => {
303 metrics.inc(MetricStatus::AdapterUnavailable);
304 return (StatusCode::SERVICE_UNAVAILABLE, "adapter not ready")
305 .into_response();
306 }
307 };
308 let system_vars = adapter_client.get_system_vars().await;
309 let Some(issuer) = OIDC_ISSUER.get(system_vars.dyncfgs()) else {
310 // No OAuth authorization server is configured. Per RFC 9728 the
311 // document MUST contain at least one entry in
312 // `authorization_servers`, so the honest response is 404 rather
313 // than an empty document that misleads the client.
314 warn!("oauth-protected-resource: oidc_issuer is unset; cannot publish");
315 metrics.inc(MetricStatus::NoIssuer);
316 return StatusCode::NOT_FOUND.into_response();
317 };
318 if let Err(err) = validate_issuer_url(&issuer) {
319 // Don't echo the issuer into the WARN: a userinfo-bearing value
320 // would turn this log line into a credential dump. The error
321 // reason is specific enough.
322 warn!(error = %err, "oauth-protected-resource: refusing to publish invalid oidc_issuer");
323 metrics.inc(MetricStatus::InvalidIssuer);
324 return (StatusCode::SERVICE_UNAVAILABLE, err).into_response();
325 }
326
327 // We still publish when no audience is configured (mirroring the
328 // authenticator, which warns and skips `aud` validation), but a resource
329 // server that does not bind tokens to its own audience is exposed to
330 // same-issuer token reuse, so make the gap visible to operators.
331 if OIDC_AUDIENCE
332 .get(system_vars.dyncfgs())
333 .as_array()
334 .is_none_or(|audiences| audiences.is_empty())
335 {
336 warn!(
337 "oauth-protected-resource: publishing with oidc_audience unset; tokens from \
338 this issuer are not audience-bound to this resource"
339 );
340 }
341 issuer.to_string()
342 }
343 };
344
345 let metadata = ProtectedResourceMetadata {
346 resource,
347 authorization_servers: vec![issuer],
348 bearer_methods_supported: vec!["header".to_string()],
349 scopes_supported: vec![MCP_SCOPE.to_string()],
350 };
351
352 let mut response = axum::Json(metadata).into_response();
353 response.headers_mut().insert(
354 http::header::CACHE_CONTROL,
355 HeaderValue::from_static(METADATA_CACHE_CONTROL),
356 );
357 metrics.inc(MetricStatus::Ok);
358 response
359}
360
361/// Validates `oidc_issuer` before it is published. Required: parses as
362/// a URL, scheme is `https` or `http`, no userinfo (we publish it on a
363/// public endpoint), no query or fragment (RFC 8414 §2). The `http`
364/// scheme is permitted to ease local dev; OAuth 2.1 §3.1 forbids it in
365/// production but enforcement is the operator's responsibility.
366///
367/// The caller publishes the **original** value (not a re-serialised
368/// `Url`) because `url::Url` silently normalises some forms (e.g. adds
369/// a trailing slash to a bare authority), and a mutated issuer would
370/// not match the `iss` claim in tokens minted by the IdP.
371fn validate_issuer_url(issuer: &str) -> Result<(), &'static str> {
372 let url = Url::parse(issuer).map_err(|_| "oidc_issuer is not a parseable URL")?;
373 if !matches!(url.scheme(), "https" | "http") {
374 return Err("oidc_issuer must use the https or http scheme");
375 }
376 if !url.username().is_empty() || url.password().is_some() {
377 return Err("oidc_issuer must not contain userinfo");
378 }
379 if url.query().is_some() || url.fragment().is_some() {
380 return Err("oidc_issuer must not contain a query or fragment");
381 }
382 Ok(())
383}
384
385/// Builds the absolute URL of the protected resource metadata document
386/// for use as the `resource_metadata` parameter in a `WWW-Authenticate`
387/// challenge. Returns `None` if the request lacks enough host information
388/// to construct a URL; the caller is expected to skip the Bearer challenge
389/// in that case rather than emit a malformed value.
390pub(crate) fn metadata_url(req: &Request, http_host_name: Option<&str>) -> Option<String> {
391 let host = resolve_host(req, http_host_name)?;
392 Some(format!(
393 "{PUBLISHED_SCHEME}://{host}{PROTECTED_RESOURCE_METADATA_PATH}"
394 ))
395}
396
397/// Resolves the host string to embed in published absolute URLs.
398///
399/// Prefers the operator-configured `http_host_name`; falls back to the
400/// request's `Host` header. **Never consults `X-Forwarded-*`**. See the
401/// module-level "Host derivation" notes.
402///
403/// The returned value is parsed through [`http::uri::Authority`] before
404/// it is returned, so it is guaranteed to be a syntactically valid
405/// `host[:port]` per RFC 3986. This is the second layer of defense
406/// against header-smuggling attacks: even if a future change accepts a
407/// malicious value as input, the parser rejects anything containing
408/// characters outside the URI host grammar (notably `"`, whitespace,
409/// `;`, etc.) so the value cannot break out of the quoted
410/// `resource_metadata="..."` parameter in a `WWW-Authenticate` challenge
411/// or smuggle additional fields into the published `resource` URL.
412fn resolve_host(req: &Request, http_host_name: Option<&str>) -> Option<String> {
413 let candidate = http_host_name
414 .map(str::trim)
415 .filter(|s| !s.is_empty())
416 .map(str::to_string)
417 .or_else(|| {
418 req.headers()
419 .get(http::header::HOST)
420 .and_then(|v| v.to_str().ok())
421 .map(str::trim)
422 .filter(|s| !s.is_empty())
423 .map(str::to_string)
424 })?;
425 // Reject `user@host` explicitly. `Authority::as_str()` keeps userinfo,
426 // so the round-trip below would pass it through, letting an attacker
427 // poison the published URLs via a forged `Host: bobby@evil...`.
428 if candidate.contains('@') {
429 return None;
430 }
431 // Round-trip through `http::uri::Authority` to confirm the value is
432 // a syntactically valid `host[:port]`. This rejects header-smuggling
433 // payloads (quotes, whitespace, control chars, parameter-delimiters)
434 // that `HeaderValue::from_str` lets through.
435 let authority = candidate.parse::<http::uri::Authority>().ok()?;
436 if authority.as_str() != candidate {
437 return None;
438 }
439 Some(candidate)
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445
446 use axum::body::Body;
447 use http::Request;
448
449 fn req_with_host(host: &str) -> Request<Body> {
450 Request::builder()
451 .header(http::header::HOST, host)
452 .body(Body::empty())
453 .unwrap()
454 }
455
456 /// Pin the serialized field names; clients key off them.
457 #[mz_ore::test]
458 fn test_metadata_serialization_matches_rfc9728_field_names() {
459 let metadata = ProtectedResourceMetadata {
460 resource: "https://mcp.example.com/api/mcp".to_string(),
461 authorization_servers: vec!["https://auth.example.com".to_string()],
462 bearer_methods_supported: vec!["header".to_string()],
463 scopes_supported: vec!["mcp.read".to_string()],
464 };
465 let json = serde_json::to_value(&metadata).unwrap();
466 assert_eq!(
467 json,
468 serde_json::json!({
469 "resource": "https://mcp.example.com/api/mcp",
470 "authorization_servers": ["https://auth.example.com"],
471 "bearer_methods_supported": ["header"],
472 "scopes_supported": ["mcp.read"],
473 }),
474 );
475 }
476
477 /// The well-known path is part of the public contract with clients.
478 #[mz_ore::test]
479 fn test_well_known_path_is_rfc9728_canonical() {
480 assert_eq!(
481 PROTECTED_RESOURCE_METADATA_PATH,
482 "/.well-known/oauth-protected-resource",
483 );
484 }
485
486 /// `http_host_name` beats the request's Host header.
487 #[mz_ore::test]
488 fn test_resolve_host_prefers_http_host_name() {
489 let req = req_with_host("internal.local:6876");
490 assert_eq!(
491 resolve_host(&req, Some("public.example.com")).as_deref(),
492 Some("public.example.com"),
493 );
494 }
495
496 /// Empty or whitespace-only `http_host_name` falls back to `Host`.
497 #[mz_ore::test]
498 fn test_resolve_host_ignores_blank_config() {
499 let req = req_with_host("public.example.com");
500 for blank in ["", " ", "\t"] {
501 assert_eq!(
502 resolve_host(&req, Some(blank)).as_deref(),
503 Some("public.example.com"),
504 "blank http_host_name = {blank:?} should fall back to Host",
505 );
506 }
507 }
508
509 /// Host header is the final fallback; port must be preserved.
510 #[mz_ore::test]
511 fn test_resolve_host_falls_back_to_host_header_with_port() {
512 let req = req_with_host("example.com:8080");
513 assert_eq!(
514 resolve_host(&req, None).as_deref(),
515 Some("example.com:8080"),
516 );
517 }
518
519 /// Security regression guard: `X-Forwarded-Host` is never trusted
520 /// (see module-level "Host derivation" notes).
521 #[mz_ore::test]
522 fn test_resolve_host_ignores_x_forwarded_host() {
523 let req = Request::builder()
524 .header(http::header::HOST, "honest.example.com")
525 .header("x-forwarded-host", "evil.example.com")
526 .body(Body::empty())
527 .unwrap();
528 assert_eq!(
529 resolve_host(&req, None).as_deref(),
530 Some("honest.example.com"),
531 "X-Forwarded-Host must not influence the resolved host",
532 );
533 }
534
535 /// No host config and no `Host` header → `None`.
536 #[mz_ore::test]
537 fn test_resolve_host_returns_none_when_unavailable() {
538 let req = Request::builder().body(Body::empty()).unwrap();
539 assert_eq!(resolve_host(&req, None), None);
540 }
541
542 /// Defense in depth against Host-header smuggling: per-payload
543 /// comments below describe the specific failure each one would cause.
544 #[mz_ore::test]
545 fn test_resolve_host_rejects_smuggled_characters() {
546 for malicious in [
547 // The headline regression: closes the quoted
548 // resource_metadata parameter and injects a second one.
549 "attacker.example.net\" foo=bar",
550 // Whitespace splits the `host:port` token.
551 "host with space.example.com",
552 // Semicolons inside a quoted parameter still terminate
553 // `auth-param` values in some lenient parsers.
554 "host\";evil=1",
555 // Backslash is reserved in URI host grammar.
556 "host\\backslash",
557 // Quote alone is enough to terminate the parameter.
558 "host\"quote",
559 ] {
560 let req = req_with_host(malicious);
561 assert_eq!(
562 resolve_host(&req, None),
563 None,
564 "smuggling payload via Host header must be rejected: {malicious:?}",
565 );
566 assert_eq!(
567 resolve_host(&req, Some(malicious)),
568 None,
569 "smuggling payload via http_host_name must be rejected: {malicious:?}",
570 );
571 }
572 }
573
574 /// `http::uri::Authority` accepts a `userinfo@host` form, but the
575 /// round-trip check in `resolve_host` must reject it: if we accepted
576 /// it, an attacker could send `Host: bobby@evil.example.com` and
577 /// poison the published `resource` URL with their own prefix.
578 #[mz_ore::test]
579 fn test_resolve_host_rejects_userinfo() {
580 for malicious in [
581 "user@host.example.com",
582 "user:pass@host.example.com",
583 "@host.example.com",
584 "user@host.example.com:8080",
585 "user:pass@host.example.com:8080",
586 ] {
587 let req = req_with_host(malicious);
588 assert_eq!(
589 resolve_host(&req, None),
590 None,
591 "userinfo in Host header must be rejected: {malicious:?}",
592 );
593 assert_eq!(
594 resolve_host(&req, Some(malicious)),
595 None,
596 "userinfo in http_host_name must be rejected: {malicious:?}",
597 );
598 }
599 }
600
601 /// Pin the assembled URL: accidental path drift (extra slashes,
602 /// dropped suffix) breaks every connected client.
603 #[mz_ore::test]
604 fn test_metadata_url_assembles_canonical_suffix() {
605 let req = req_with_host("example.com");
606 assert_eq!(
607 metadata_url(&req, Some("public.example.com")).as_deref(),
608 Some("https://public.example.com/.well-known/oauth-protected-resource"),
609 );
610 }
611
612 /// Pin the path-suffixed aliases; strict RFC 9728 §3.1 clients
613 /// probe these before the bare URI.
614 #[mz_ore::test]
615 fn test_path_suffixed_alias_paths_are_correct() {
616 assert_eq!(
617 PROTECTED_RESOURCE_METADATA_PATH_AGENT,
618 "/.well-known/oauth-protected-resource/api/mcp/agent",
619 );
620 assert_eq!(
621 PROTECTED_RESOURCE_METADATA_PATH_DEVELOPER,
622 "/.well-known/oauth-protected-resource/api/mcp/developer",
623 );
624 }
625
626 /// Pin the wire-visible scope string; clients ask the IdP for a
627 /// token with this exact value.
628 #[mz_ore::test]
629 fn test_mcp_scope_constant() {
630 assert_eq!(MCP_SCOPE, "mcp.read");
631 }
632
633 /// Counter families only appear in `gather()` after a label
634 /// combination is observed, so each status value is touched first.
635 #[mz_ore::test]
636 fn test_metric_registers() {
637 let registry = MetricsRegistry::new();
638 let metrics = OauthMetadataMetrics::register_into(®istry);
639 for status in [
640 MetricStatus::Ok,
641 MetricStatus::Disabled,
642 MetricStatus::NoIssuer,
643 MetricStatus::InvalidIssuer,
644 MetricStatus::NoHost,
645 MetricStatus::AdapterUnavailable,
646 ] {
647 metrics.inc(status);
648 }
649 let names: Vec<String> = registry
650 .gather()
651 .iter()
652 .map(|m| m.name().to_string())
653 .collect();
654 assert!(
655 names
656 .iter()
657 .any(|n| n == "mz_oauth_protected_resource_metadata_requests_total"),
658 "metric should be registered, got: {names:?}",
659 );
660 }
661
662 /// IPv6 literals (`[::1]:8080`) are valid `Authority` syntax and
663 /// must round-trip. A regression silently breaks IPv6 deployments.
664 #[mz_ore::test]
665 fn test_resolve_host_accepts_ipv6_literal() {
666 for host in ["[::1]", "[::1]:8080", "[2001:db8::1]:443"] {
667 let req = req_with_host(host);
668 assert_eq!(
669 resolve_host(&req, None).as_deref(),
670 Some(host),
671 "IPv6 literal must round-trip through Authority: {host:?}",
672 );
673 }
674 }
675
676 /// Well-formed issuers pass through unchanged; normalisation
677 /// would break byte-for-byte match against the `iss` token claim.
678 #[mz_ore::test]
679 fn test_validate_issuer_url_accepts_well_formed() {
680 for issuer in [
681 "https://issuer.example.com",
682 "https://issuer.example.com/realms/main",
683 "http://localhost:8080",
684 ] {
685 assert_eq!(
686 validate_issuer_url(issuer),
687 Ok(()),
688 "expected {issuer:?} to pass validation",
689 );
690 }
691 }
692
693 /// Pin the authenticator-to-discovery mapping so a refactor doesn't
694 /// silently steer MCP clients into a flow the listener can't honour.
695 #[mz_ore::test]
696 fn test_mcp_oauth_discovery_for_authenticator() {
697 use listeners::AuthenticatorKind;
698 // Oidc always advertises, regardless of the Frontegg issuer URL.
699 for issuer in [None, Some("https://ignored.example.com")] {
700 assert_eq!(
701 McpOAuthDiscovery::for_authenticator(AuthenticatorKind::Oidc, issuer),
702 McpOAuthDiscovery::Oidc,
703 );
704 }
705 // Frontegg requires a valid issuer URL; absent or invalid → Disabled.
706 assert_eq!(
707 McpOAuthDiscovery::for_authenticator(AuthenticatorKind::Frontegg, None),
708 McpOAuthDiscovery::Disabled,
709 );
710 assert_eq!(
711 McpOAuthDiscovery::for_authenticator(AuthenticatorKind::Frontegg, Some("not a url"),),
712 McpOAuthDiscovery::Disabled,
713 );
714 assert_eq!(
715 McpOAuthDiscovery::for_authenticator(
716 AuthenticatorKind::Frontegg,
717 Some("https://acme.frontegg.com"),
718 ),
719 McpOAuthDiscovery::Frontegg {
720 issuer: "https://acme.frontegg.com".to_string(),
721 },
722 );
723 // Non-token authenticators stay disabled even with an issuer URL set.
724 for kind in [
725 AuthenticatorKind::None,
726 AuthenticatorKind::Password,
727 AuthenticatorKind::Sasl,
728 ] {
729 for issuer in [None, Some("https://acme.frontegg.com")] {
730 assert_eq!(
731 McpOAuthDiscovery::for_authenticator(kind, issuer),
732 McpOAuthDiscovery::Disabled,
733 "{kind:?} must not advertise OAuth",
734 );
735 }
736 }
737 assert!(McpOAuthDiscovery::Oidc.is_enabled());
738 assert!(!McpOAuthDiscovery::Disabled.is_enabled());
739 assert!(
740 McpOAuthDiscovery::Frontegg {
741 issuer: "https://acme.frontegg.com".to_string(),
742 }
743 .is_enabled()
744 );
745 }
746
747 /// Rejects values that would confuse clients downstream or leak
748 /// embedded secrets in a public document.
749 #[mz_ore::test]
750 fn test_validate_issuer_url_rejects_invalid() {
751 // Format: (issuer, expected substring of the error reason).
752 let cases: &[(&str, &str)] = &[
753 ("not a url", "parseable"),
754 ("issuer.example.com", "parseable"),
755 ("ftp://issuer.example.com", "scheme"),
756 ("https://user:pass@issuer.example.com", "userinfo"),
757 ("https://user@issuer.example.com", "userinfo"),
758 ("https://issuer.example.com?foo=bar", "query"),
759 ("https://issuer.example.com#frag", "query"),
760 ];
761 for (issuer, expected_substr) in cases {
762 let err = validate_issuer_url(issuer)
763 .expect_err(&format!("expected {issuer:?} to be rejected as invalid",));
764 assert!(
765 err.contains(expected_substr),
766 "for {issuer:?}, expected reason to contain {expected_substr:?}, got {err:?}",
767 );
768 }
769 }
770}