Skip to main content

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(&registry);
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}