Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions misc/shlib/shlib.bash
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,8 @@ trufflehog_jq_filter_common() {
.Raw != "jdbc:postgresql://%s:%s/materialize" and
.Raw != "postgres://postgres:$MATERIALIZE_PROD_SANDBOX_RDS_PASSWORD@$MATERIALIZE_PROD_SANDBOX_RDS_HOSTNAME:5432" and
.Raw != "http://user:pass@example.com" and
.Raw != "https://user:pass@issuer.example.com" and
.Raw != "https://user@issuer.example.com" and
.Raw != "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDDC5MP3v1BHOgI\n5SsmrW8mjxzQGOz0IlC5jp1muW/kpEoE9TG317TEnO5Uye6zZudkFCP8YGEiN3Mc\nFbTM7eX6PjAPdnGU7khuUt/20ZM+NX5kWZPrmPTh4WQaDCL7ah1LqzBaUAMaSXq8\niuy7LGJNF8wdx8L5BjDiGTTxZXOg0Haxknc7Mbiwc9z8eb7omvzQzsOwyqocrF2u\nz86TzX1jtHP48i5CxoRHKxE94De3tNxjT/Y3OZlS4QS7iekAOQ04DVV3GIHvRUXN\n2H8ayy4+yOdhHn6ER5Jn3lti1Q5XSrxkrYn7L1Vcj6IwZQhhF5vc+ovxOYb+8ert\nEo97tIkLAgMBAAECggEAQteHHRPKz9Mzs8Sxvo4GPv0hnzFDl0DhUE4PJCKdtYoV\n8dADq2DJiu3LAZS4cJPt7Y63bGitMRg2oyPPM8G9pD5Goy3wq9zjRqexKDlXUCTt\n/T7zofRny7c94m1RWb7ablGq/vBXt90BqnajvVtvDsN+iKAqccQM4ZdI3QdrEmt1\ncHex924itzG/mqbFTAfAmVj1ZsRnJp55Txy2gqq7jX00xDM8+H49SRvUu49N64LQ\n6BUWCgWCJePRtgjSHjboAzPqSkMdaTE/WDY2zgGF3Qfq4f6JCHKfm4QylCH4gYUU\n1Kf7ttmhu9NoZO+hczobKkxP9RtXfyTRH2bsJXy2HQKBgQDhHgavxk/ln5mdMGGw\nrQud2vF9n7UwFiysYxocIC5/CWD0GAhnawchjPypbW/7vKM5Z9zhW3eH1U9P13sa\n2xHfrU5BZ16rxoBbKNpcr7VeEbUBAsDoGV24xjoecp7rB2hZ+mGik5/5Ig1Rk1KH\ndcvYy2KSi1h4Sm+mXwimmA4VDQKBgQDdzW+5FPbdM2sUB2gLMQtn3ICjDSu6IQ+k\nd0p3WlTIT51RUsPXXKkk96O5anUbeB3syY8tSKPGggsaXaeL3o09yIamtERgCnn3\nd9IS+4VKPWQlFUICU1KrD+TO7IYIX04iXBuVE5ihv0q3mslhDotmX4kS38NtKEFF\njLjA2RvAdwKBgAFkIxxw+Ett+hALnX7vAtRd5wIku4TpjisejanA1Si50RyRDXQ+\nKBQf/+u4HmoK12Nibe4Cl7GCMvRGW59l3S1pr8MdtWsQVfi6Puc1usQzDdBMyQ5m\nIbsjlnZbtPm02QM9Vd8gVGvAtx5a77aglrrnPtuy+r/7jccUbURCSkv9AoGAH9m3\nWGmVRZBzqO2jWDATxjdY1ZE3nUPQHjrvG5KCKD2ehqYO72cj9uYEwcRyyp4GFhGf\nmM4cjo3wEDowrBoqSBv6kgfC5dO7TfkL1qP9sPp93gFeeD0E2wGuRrSaTqt46eA2\nKcMloNx6W0FD98cB55KCeY5eXtdwAA/EHBVRMeMCgYAd3n6PcL6rVXyE3+wRTKK4\n+zvx5sjTAnljr5ttbEnpZafzrYIfDpB8NNjexy83AeC0O13LvSHIFoTwP8sywJRO\nRxbPMjhEBdVZ5NxlxYer7yKN+h5OBJfrLswPku7y4vdFYK3x/lMuNQO61hb1VFHc\nT2BDTbF0QSlPxFsv18B9zg==\n-----END PRIVATE KEY-----\n" and
.Raw != "postgres://materialize:materialize@environmentd:6875" and
.Raw != "postgres://MATERIALIZE_USERNAME:APP_SPECIFIC_PASSWORD@MATERIALIZE_HOST:6875" and
Expand Down
178 changes: 154 additions & 24 deletions src/environmentd/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ mod memory;
mod metrics;
mod metrics_public;
mod metrics_viz;
pub(crate) mod oauth_metadata;
mod probe;
mod prometheus;
mod root;
Expand Down Expand Up @@ -157,10 +158,24 @@ pub struct HttpConfig {
pub allowed_origin_list: Vec<HeaderValue>,
pub active_connection_counter: ConnectionCounter,
pub helm_chart_version: Option<String>,
/// Externally-visible host name for this environment (without scheme).
///
/// Used as the canonical host when constructing absolute URLs that the
/// server needs to publish (e.g. the OAuth Protected Resource Metadata
/// `resource` field, RFC 9728). When `None`, callers fall back to the
/// request's `Host` header, which is correct for unproxied dev setups
/// but loses fidelity behind a load balancer that rewrites Host.
///
/// We deliberately do NOT consult `X-Forwarded-Host` or
/// `X-Forwarded-Proto`: there is no proxy-trust model in environmentd
/// today, and an attacker reaching the server directly can otherwise
/// poison the published metadata URLs.
pub http_host_name: Option<String>,
pub concurrent_webhook_req: Arc<tokio::sync::Semaphore>,
pub metrics: Metrics,
pub metrics_registry: MetricsRegistry,
pub mcp_metrics: mcp_metrics::McpMetrics,
pub oauth_metadata_metrics: oauth_metadata::OauthMetadataMetrics,
pub allowed_roles: AllowedRoles,
pub internal_route_config: Arc<InternalRouteConfig>,
pub routes_enabled: HttpRoutesEnabled,
Expand Down Expand Up @@ -213,10 +228,12 @@ impl HttpServer {
allowed_origin_list,
active_connection_counter,
helm_chart_version,
http_host_name,
concurrent_webhook_req,
metrics,
metrics_registry,
mcp_metrics,
oauth_metadata_metrics,
allowed_roles,
internal_route_config,
routes_enabled,
Expand All @@ -238,10 +255,12 @@ impl HttpServer {
let frontegg_middleware = frontegg.clone();
let oidc_middleware_rx = oidc_rx.clone();
let adapter_client_middleware_rx = adapter_client_rx.clone();
let http_host_name_middleware = http_host_name.clone();
let auth_middleware = middleware::from_fn(move |req, next| {
let frontegg = frontegg_middleware.clone();
let oidc_rx = oidc_middleware_rx.clone();
let adapter_client_rx = adapter_client_middleware_rx.clone();
let http_host_name = http_host_name_middleware.clone();
async move {
http_auth(
req,
Expand All @@ -252,6 +271,7 @@ impl HttpServer {
oidc_rx,
adapter_client_rx,
allowed_roles,
http_host_name,
)
.await
}
Expand Down Expand Up @@ -504,6 +524,39 @@ impl HttpServer {
if routes_enabled.mcp_agent || routes_enabled.mcp_developer {
use tracing::info;

// RFC 9728 Protected Resource Metadata. Public route — MCP
// clients fetch it before they have a token. Sits on its own
// router so the auth middleware never runs on it. The handler
// returns 404 when no OAuth authorization server is configured
// (`oidc_issuer` dyncfg unset) OR when the listener uses the
// `None` authenticator (no token would ever be validated), so
// it is safe to enable unconditionally whenever MCP is enabled.
// RFC 9728 §3.1 lets clients look up per-resource metadata
// via a path-suffixed well-known URI before falling back to
// the bare one. The MCP endpoints share an identical
// metadata view today, so we serve the same handler at all
// three paths.
let oauth_metadata_router = Router::new()
.route(
oauth_metadata::PROTECTED_RESOURCE_METADATA_PATH,
routing::get(oauth_metadata::handle_protected_resource_metadata),
)
.route(
oauth_metadata::PROTECTED_RESOURCE_METADATA_PATH_AGENT,
routing::get(oauth_metadata::handle_protected_resource_metadata),
)
.route(
oauth_metadata::PROTECTED_RESOURCE_METADATA_PATH_DEVELOPER,
routing::get(oauth_metadata::handle_protected_resource_metadata),
)
.layer(Extension(adapter_client_rx.clone()))
.layer(Extension(oauth_metadata::DiscoveryConfig {
http_host_name: http_host_name.clone(),
authenticator_kind,
}))
.layer(Extension(oauth_metadata_metrics.clone()));
router = router.merge(oauth_metadata_router);

let mut mcp_router = Router::new();

if routes_enabled.mcp_agent {
Expand Down Expand Up @@ -717,7 +770,7 @@ async fn x_materialize_user_header_auth(
Ok(next.run(req).await)
}

type Delayed<T> = Shared<oneshot::Receiver<T>>;
pub(crate) type Delayed<T> = Shared<oneshot::Receiver<T>>;

#[derive(Clone)]
enum ConnProtocol {
Expand Down Expand Up @@ -881,6 +934,30 @@ where
}
}

/// Per-request decision about which `WWW-Authenticate` challenges to emit
/// on a 401, computed by the auth middleware based on the request path.
///
/// Carries both the `Basic` toggle (today's behavior, kept for the SQL HTTP
/// layer and friends) and an optional `Bearer` challenge with a
/// `resource_metadata` URL per RFC 9728. The latter is only set on routes
/// that opt in to OAuth discovery (today: `/api/mcp/*`); other routes
/// emit only `Basic` so their behavior is unchanged.
#[derive(Debug, Clone, Default)]
pub(crate) struct WwwAuthenticateChallenges {
/// Whether to emit `WWW-Authenticate: Basic realm=Materialize`.
pub include_basic: bool,
/// If `Some`, also emit `WWW-Authenticate: Bearer
/// resource_metadata="<url>"`. The URL points at this server's RFC 9728
/// Protected Resource Metadata document, which advertises the
/// authorization server the client should use.
pub bearer_resource_metadata: Option<String>,
/// If `Some`, also emit `scope="<scope>"` inside the Bearer challenge.
/// Tells clients which OAuth scope to request a token with for this
/// resource. Only set in conjunction with `bearer_resource_metadata`
/// (a scope challenge with no resource hint would be confusing).
pub bearer_scope: Option<&'static str>,
}

#[derive(Debug, Error)]
pub(crate) enum AuthError {
#[error("role dissallowed")]
Expand All @@ -889,7 +966,7 @@ pub(crate) enum AuthError {
Frontegg(#[from] FronteggError),
#[error("missing authorization header")]
MissingHttpAuthentication {
include_www_authenticate_header: bool,
challenges: WwwAuthenticateChallenges,
},
#[error("{0}")]
MismatchedUser(String),
Expand All @@ -913,13 +990,42 @@ impl IntoResponse for AuthError {
// exception — its payload is a sanitized `OidcError::Display` that the
// console embeds in the login-page error.
let body = match &self {
AuthError::MissingHttpAuthentication {
include_www_authenticate_header,
} if *include_www_authenticate_header => {
headers.insert(
http::header::WWW_AUTHENTICATE,
HeaderValue::from_static("Basic realm=Materialize"),
);
// Bearer goes first so OAuth-aware clients see it before the
// Basic fallback. RFC 7235 allows emitting multiple
// `WWW-Authenticate` headers; we use one per scheme so each
// challenge is unambiguously framed — some parsers struggle
// with multiple schemes on a single header value.
AuthError::MissingHttpAuthentication { challenges } => {
if let Some(resource_metadata) = &challenges.bearer_resource_metadata {
// `scope` is hard-coded to a vetted constant
// (`MCP_SCOPE`); only `resource_metadata` is derived
// from a header value, and `resolve_host` has already
// round-tripped it through the URI grammar. The quoted
// form follows RFC 6749 §3.3 / RFC 6750 §3.
let value = match &challenges.bearer_scope {
Some(scope) => format!(
"Bearer scope=\"{scope}\", resource_metadata=\"{resource_metadata}\"",
),
None => format!("Bearer resource_metadata=\"{resource_metadata}\""),
};
match HeaderValue::from_str(&value) {
Ok(v) => {
headers.append(http::header::WWW_AUTHENTICATE, v);
}
Err(e) => {
warn!(
"skipping Bearer WWW-Authenticate challenge: invalid header \
value derived from resource_metadata={resource_metadata:?}: {e}",
);
}
}
}
if challenges.include_basic {
headers.append(
http::header::WWW_AUTHENTICATE,
HeaderValue::from_static("Basic realm=Materialize"),
);
}
"unauthorized".to_string()
}
AuthError::OidcFailed(message) => message.clone(),
Expand Down Expand Up @@ -998,6 +1104,7 @@ async fn http_auth(
oidc_rx: Delayed<mz_authenticator::GenericOidcAuthenticator>,
adapter_client_rx: Delayed<Client>,
allowed_roles: AllowedRoles,
http_host_name: Option<String>,
) -> Result<impl IntoResponse, AuthError> {
let creds = if let Some(basic) = req.headers().typed_get::<Authorization<Basic>>() {
Some(Credentials::Password {
Expand Down Expand Up @@ -1056,10 +1163,35 @@ async fn http_auth(
}

let path = req.uri().path();
let include_www_authenticate_header = path == "/"
let include_basic = path == "/"
|| PROFILING_API_ENDPOINTS
.iter()
.any(|prefix| path.starts_with(prefix));
.any(|prefix| path.starts_with(prefix))
|| path.starts_with("/api/mcp/");
// For the MCP routes we additionally advertise OAuth via RFC 9728 so
// clients like Claude Desktop's Custom Connectors and ChatGPT remote
// MCP can discover the authorization server. The `Basic` challenge
// stays on these routes so existing curl/Bearer-already users still
// see a usable challenge. See `crate::http::oauth_metadata` for the
// discovery document.
// OAuth discovery is only meaningful when the listener actually
// validates tokens. When the listener uses the `None` authenticator
// (anonymous_http_user), there is no OAuth flow to advertise.
let (bearer_resource_metadata, bearer_scope) = if path.starts_with("/api/mcp/")
&& !matches!(authenticator_kind, listeners::AuthenticatorKind::None)
{
(
oauth_metadata::metadata_url(&req, http_host_name.as_deref()),
Some(oauth_metadata::MCP_SCOPE),
)
} else {
(None, None)
};
let challenges = WwwAuthenticateChallenges {
include_basic,
bearer_resource_metadata,
bearer_scope,
};
let authenticator = get_authenticator(
authenticator_kind,
creds.as_ref(),
Expand All @@ -1069,13 +1201,7 @@ async fn http_auth(
)
.await;

let user = auth(
&authenticator,
creds,
allowed_roles,
include_www_authenticate_header,
)
.await?;
let user = auth(&authenticator, creds, allowed_roles, &challenges).await?;

// Add the authenticated user as an extension so downstream handlers can
// inspect it if necessary.
Expand Down Expand Up @@ -1164,7 +1290,11 @@ async fn init_ws(
&adapter_client_rx,
)
.await;
let user = auth(&authenticator, Some(creds), allowed_roles, false).await?;
// WebSocket init: no 401-with-challenge contract — the
// client is reading WS frames, not parsing HTTP headers — so
// we just suppress challenge emission entirely.
let no_challenges = WwwAuthenticateChallenges::default();
let user = auth(&authenticator, Some(creds), allowed_roles, &no_challenges).await?;
user
}
(None, None) => anyhow::bail!("expected auth information"),
Expand Down Expand Up @@ -1271,7 +1401,7 @@ async fn auth(
authenticator: &Authenticator,
creds: Option<Credentials>,
allowed_roles: AllowedRoles,
include_www_authenticate_header: bool,
challenges: &WwwAuthenticateChallenges,
) -> Result<AuthedUser, AuthError> {
let (name, external_metadata_rx, authenticated, groups) = match authenticator {
Authenticator::Frontegg(frontegg) => match creds {
Expand All @@ -1292,7 +1422,7 @@ async fn auth(
}
None => {
return Err(AuthError::MissingHttpAuthentication {
include_www_authenticate_header,
challenges: challenges.clone(),
});
}
},
Expand All @@ -1306,7 +1436,7 @@ async fn auth(
}
_ => {
return Err(AuthError::MissingHttpAuthentication {
include_www_authenticate_header,
challenges: challenges.clone(),
});
}
},
Expand All @@ -1315,7 +1445,7 @@ async fn auth(
// If we do, it's a server misconfiguration.
// Just in case, we return a 401 rather than panic.
return Err(AuthError::MissingHttpAuthentication {
include_www_authenticate_header,
challenges: challenges.clone(),
});
}
Authenticator::Oidc(oidc) => match creds {
Expand All @@ -1330,7 +1460,7 @@ async fn auth(
}
_ => {
return Err(AuthError::MissingHttpAuthentication {
include_www_authenticate_header,
challenges: challenges.clone(),
});
}
},
Expand Down
Loading
Loading