This is an automated email from the ASF dual-hosted git repository.
FreeOnePlus pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris-mcp-server.git
The following commit(s) were added to refs/heads/master by this push:
new b29dcd0 feat: support OAuth client metadata documents (#122)
b29dcd0 is described below
commit b29dcd004976065a7f5dfec618d644ab0df43828
Author: Yijia Su <[email protected]>
AuthorDate: Wed Jul 29 22:40:04 2026 +0800
feat: support OAuth client metadata documents (#122)
---
CHANGELOG.md | 5 +
README.md | 52 +-
.../auth/doris_oauth_client_metadata.py | 522 ++++++++++++++++++++
doris_mcp_server/auth/doris_oauth_handlers.py | 61 ++-
doris_mcp_server/auth/doris_oauth_provider.py | 96 +++-
doris_mcp_server/auth/doris_oauth_store.py | 2 +
doris_mcp_server/auth/doris_oauth_types.py | 1 +
doris_mcp_server/utils/config.py | 45 ++
test/auth/test_doris_oauth_client_metadata.py | 549 +++++++++++++++++++++
test/auth/test_doris_oauth_routes.py | 2 +
test/security/test_effective_auth_config.py | 72 +++
11 files changed, 1399 insertions(+), 8 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 448d9a5..81a80a7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -30,6 +30,9 @@ under **Unreleased** until a new version is selected and
published.
- MCP `2026-07-28` stateless protocol support on Streamable HTTP and stdio,
with a `2025-11-25` migration path.
- Doris-backed OAuth with per-user Doris connection pools and operation gates.
+- Client ID Metadata Document discovery for Doris-backed OAuth, including
+ bounded retrieval, DNS pinning, cache-control handling, and consent-screen
+ client/redirect host context.
- Per-request client capability enforcement, cache hints, typed protocol
errors, and deterministic product identity.
- Real Doris process tests covering Streamable HTTP and stdio.
@@ -42,6 +45,8 @@ under **Unreleased** until a new version is selected and
published.
`serverInfo`, and health endpoints on one product version source.
- Documented the modern request headers, migration steps, and deployment
constraints.
+- Positioned Dynamic Client Registration as a compatibility fallback behind
+ preconfigured clients and Client ID Metadata Documents.
### Fixed
diff --git a/README.md b/README.md
index d0e634b..e02275a 100644
--- a/README.md
+++ b/README.md
@@ -276,6 +276,11 @@ cp .env.example .env
* `OAUTH_SCOPE` / `OAUTH_REQUIRED_SCOPE`: Allowed and mandatory external
OAuth scopes
* `ENABLE_DORIS_OAUTH_AUTH`: Enable Doris-backed OAuth authentication
(default: false)
* `DORIS_OAUTH_BASE_URL`: Public base URL used by Doris-backed OAuth
discovery and token endpoints
+ * `DORIS_OAUTH_CIMD_FETCH_TIMEOUT_SECONDS`: Client ID Metadata Document
fetch timeout (default: 5)
+ * `DORIS_OAUTH_CIMD_MAX_DOCUMENT_BYTES`: Maximum Client ID Metadata
Document size (default: 5120)
+ * `DORIS_OAUTH_CIMD_DEFAULT_CACHE_SECONDS`: Cache lifetime when the
document does not provide one (default: 300)
+ * `DORIS_OAUTH_CIMD_MAX_CACHE_SECONDS`: Maximum accepted document cache
lifetime (default: 3600)
+ * `DORIS_OAUTH_CIMD_MAX_CLIENTS`: Maximum discovered Client ID Metadata
clients held in memory (default: 1000)
* `TOKEN_FILE_PATH`: Path to tokens.json file for token management
(default: tokens.json)
* `TOKEN_HOT_RELOAD`: Enable hot reloading of token configuration
(default: true)
* `TOKEN_<ID>`: Explicit static bearer token; each active token must be a
@@ -735,11 +740,46 @@ For normal MCP OAuth flows, clients do not need to pass a
long `--scopes` list.
Doris-backed OAuth still does not open prompts, ADBC, FE HTTP
profile/monitoring, audit/governance, or performance analytics in this phase
unless those paths are separately routed through per-user credentials or given
an explicit service-account/admin design.
-Dynamic Client Registration requests must include `application_type` as either
-`native` or `web`. Native clients may register a reverse-domain custom-scheme
-redirect URI or loopback HTTP URI; web clients must register a non-loopback
-HTTPS URI. The server persists the application type with the client and uses
-exact redirect URI matching during authorization.
+#### OAuth Client Registration
+
+The preferred client-registration order is:
+
+1. Use a client configured by the operator when one is available.
+2. Use a Client ID Metadata Document (CIMD) by sending its HTTPS URL as
+ `client_id`.
+3. Use Dynamic Client Registration (DCR) only as a compatibility fallback.
+
+Authorization-server metadata advertises
+`client_id_metadata_document_supported=true`. A CIMD must be a JSON object
whose
+`client_id` exactly equals the requested URL and whose `client_name` and
+`redirect_uris` are valid. This server currently accepts public CIMD clients
+with `token_endpoint_auth_method=none`, authorization-code plus optional
+refresh-token grants, the `code` response type, and exact redirect URI
+matching. Native clients may use a reverse-domain custom scheme or loopback
+HTTP URI; web clients must use non-loopback HTTPS.
+
+CIMD retrieval is fail-closed. The URL must use HTTPS, contain a path, and have
+no userinfo, fragment, backslash, or dot path segment. The resolver rejects
+special-use destination addresses, pins validated DNS results for the request,
+does not follow redirects, requires JSON, limits the response to 5 KiB by
+default, rejects embedded shared secrets or private key material, and caches
+only valid documents according to HTTP cache controls. Loopback metadata hosts
+are accepted only when the Doris OAuth issuer is itself a loopback development
+issuer. The login page displays the client and redirect hostnames and warns
+before a localhost redirect.
+
+The CIMD controls can be adjusted with
+`DORIS_OAUTH_CIMD_FETCH_TIMEOUT_SECONDS`,
+`DORIS_OAUTH_CIMD_MAX_DOCUMENT_BYTES`,
+`DORIS_OAUTH_CIMD_DEFAULT_CACHE_SECONDS`,
+`DORIS_OAUTH_CIMD_MAX_CACHE_SECONDS`, and
+`DORIS_OAUTH_CIMD_MAX_CLIENTS`.
+
+DCR remains available for older clients when
+`DORIS_OAUTH_DYNAMIC_CLIENT_REGISTRATION_MODE` permits it. DCR requests must
+include `application_type` as either `native` or `web`; the same type-specific
+and exact redirect URI rules apply. Production DCR still requires
+`ENABLE_DORIS_OAUTH_PRODUCTION_DCR=true`.
Authorization success and redirectable error responses include the exact
authorization-server issuer in the RFC 9207 `iss` parameter. Discovery
@@ -771,7 +811,7 @@ For production deployments:
* Use Doris RBAC as the final data authorization boundary and grant Doris
users only the data they should inspect.
* Do not log Doris passwords, authorization headers, access tokens, refresh
tokens, authorization codes, PKCE verifiers, or client secrets.
* Treat the `doa_` prefix as reserved for Doris-backed OAuth access tokens;
static tokens and JWT bearer values must not use it.
-* Keep Dynamic Client Registration in `auto` for loopback development or
explicitly configure production DCR with
`ENABLE_DORIS_OAUTH_PRODUCTION_DCR=true`.
+* Prefer preconfigured clients or Client ID Metadata Documents. Keep DCR as
a compatibility fallback; production DCR requires
`ENABLE_DORIS_OAUTH_PRODUCTION_DCR=true`.
### Token-Bound Database Configuration (New in v0.6.0)
diff --git a/doris_mcp_server/auth/doris_oauth_client_metadata.py
b/doris_mcp_server/auth/doris_oauth_client_metadata.py
new file mode 100644
index 0000000..fa57e3f
--- /dev/null
+++ b/doris_mcp_server/auth/doris_oauth_client_metadata.py
@@ -0,0 +1,522 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Secure OAuth Client ID Metadata Document discovery."""
+
+from __future__ import annotations
+
+import asyncio
+import ipaddress
+import json
+import re
+import socket
+import time
+from collections.abc import Awaitable, Callable, Mapping
+from dataclasses import dataclass
+from typing import Any, cast
+from urllib.parse import unquote, urlsplit
+
+import aiohttp
+
+from .doris_oauth_redirects import (
+ DorisOAuthRedirectPolicy,
+ is_loopback_host,
+)
+from .doris_oauth_scope_policy import DorisOAuthScopePolicy
+from .doris_oauth_types import TokenEndpointError
+
+FetchDocument = Callable[
+ [str],
+ Awaitable[tuple[dict[str, Any], Mapping[str, str]]],
+]
+
+_CACHE_MAX_AGE = re.compile(r"(?:^|,)\s*max-age\s*=\s*\"?(\d+)\"?", re.I)
+_PRIVATE_JWK_PARAMETERS = frozenset({"d", "p", "q", "dp", "dq", "qi", "oth",
"k"})
+
+
+class ClientMetadataError(ValueError):
+ """A Client ID Metadata Document is unavailable or invalid."""
+
+
+@dataclass(frozen=True)
+class ResolvedClientMetadata:
+ """Validated client properties used by the authorization server."""
+
+ client_id: str
+ client_name: str
+ application_type: str
+ redirect_uris: tuple[str, ...]
+ client_allowed_scopes: tuple[str, ...]
+ token_endpoint_auth_method: str = "none"
+
+
+@dataclass(frozen=True)
+class _CacheEntry:
+ metadata: ResolvedClientMetadata
+ expires_at: float
+
+
+@dataclass
+class _LockEntry:
+ lock: asyncio.Lock
+ users: int = 0
+
+
+class _PinnedResolver(aiohttp.abc.AbstractResolver):
+ """Resolve one validated hostname only to its preflighted addresses."""
+
+ def __init__(self, hostname: str, addresses: tuple[str, ...]):
+ self.hostname = hostname.lower()
+ self.addresses = addresses
+
+ async def resolve(
+ self,
+ host: str,
+ port: int = 0,
+ family: socket.AddressFamily = socket.AF_INET,
+ ) -> list[aiohttp.abc.ResolveResult]:
+ if host.lower() != self.hostname:
+ raise OSError("Unexpected metadata hostname")
+ results: list[aiohttp.abc.ResolveResult] = []
+ for address in self.addresses:
+ parsed = ipaddress.ip_address(address)
+ address_family = socket.AF_INET6 if parsed.version == 6 else
socket.AF_INET
+ if family not in {socket.AF_UNSPEC, address_family}:
+ continue
+ results.append(
+ {
+ "hostname": host,
+ "host": address,
+ "port": port,
+ "family": address_family,
+ "proto": socket.IPPROTO_TCP,
+ "flags": socket.AI_NUMERICHOST,
+ }
+ )
+ if not results:
+ raise OSError("No validated metadata address")
+ return results
+
+ async def close(self) -> None:
+ return None
+
+
+def validate_client_identifier_url(client_id: str) -> tuple[str, int]:
+ """Validate the normative Client Identifier URL syntax."""
+
+ if not isinstance(client_id, str) or not client_id:
+ raise ClientMetadataError("Client Identifier URL is missing")
+ if "\\" in client_id:
+ raise ClientMetadataError("Client Identifier URL is malformed")
+ try:
+ parsed = urlsplit(client_id)
+ port = parsed.port or 443
+ except ValueError as exc:
+ raise ClientMetadataError("Client Identifier URL is malformed") from
exc
+ if parsed.scheme.lower() != "https" or not parsed.hostname:
+ raise ClientMetadataError("Client Identifier URL must use HTTPS")
+ if parsed.username is not None or parsed.password is not None:
+ raise ClientMetadataError("Client Identifier URL must not contain
userinfo")
+ if not parsed.path:
+ raise ClientMetadataError("Client Identifier URL must contain a path")
+ if parsed.fragment:
+ raise ClientMetadataError("Client Identifier URL must not contain a
fragment")
+ for segment in parsed.path.split("/"):
+ decoded = segment
+ for _ in range(3):
+ decoded_again = unquote(decoded)
+ if decoded_again == decoded:
+ break
+ decoded = decoded_again
+ if decoded in {".", ".."}:
+ raise ClientMetadataError(
+ "Client Identifier URL must not contain dot path segments"
+ )
+ return parsed.hostname, port
+
+
+def metadata_address_allowed(address: str, issuer: str) -> bool:
+ """Return whether a resolved metadata address is safe to contact."""
+
+ parsed_address = ipaddress.ip_address(address)
+ if parsed_address.is_global:
+ return True
+ issuer_host = urlsplit(issuer).hostname
+ if not issuer_host or not is_loopback_host(issuer_host):
+ return False
+ return parsed_address.is_loopback
+
+
+class DorisOAuthClientMetadataResolver:
+ """Fetch, validate, and cache Client ID Metadata Documents."""
+
+ def __init__(
+ self,
+ *,
+ issuer: str,
+ redirect_policy: DorisOAuthRedirectPolicy,
+ scope_policy: DorisOAuthScopePolicy,
+ timeout_seconds: int = 5,
+ max_document_bytes: int = 5120,
+ default_cache_seconds: int = 300,
+ max_cache_seconds: int = 3600,
+ max_cache_entries: int = 1000,
+ fetch_document: FetchDocument | None = None,
+ ):
+ self.issuer = issuer
+ self.redirect_policy = redirect_policy
+ self.scope_policy = scope_policy
+ self.timeout_seconds = timeout_seconds
+ self.max_document_bytes = max_document_bytes
+ self.default_cache_seconds = default_cache_seconds
+ self.max_cache_seconds = max_cache_seconds
+ self.max_cache_entries = max_cache_entries
+ self._fetch_document_override = fetch_document
+ self._cache: dict[str, _CacheEntry] = {}
+ self._locks: dict[str, _LockEntry] = {}
+
+ async def resolve(self, client_id: str) -> ResolvedClientMetadata:
+ """Resolve one URL client ID without caching invalid responses."""
+
+ validate_client_identifier_url(client_id)
+ cached = self._cache.get(client_id)
+ now = time.monotonic()
+ if cached and cached.expires_at > now:
+ return cached.metadata
+
+ lock_entry = self._locks.get(client_id)
+ if lock_entry is None:
+ lock_entry = _LockEntry(lock=asyncio.Lock())
+ self._locks[client_id] = lock_entry
+ lock_entry.users += 1
+ try:
+ async with lock_entry.lock:
+ cached = self._cache.get(client_id)
+ now = time.monotonic()
+ if cached and cached.expires_at > now:
+ return cached.metadata
+
+ fetcher = self._fetch_document_override or self._fetch_document
+ document, headers = await fetcher(client_id)
+ metadata = self._validate_document(client_id, document)
+ cache_seconds = self._cache_seconds(headers)
+ if cache_seconds > 0:
+ self._make_cache_space(client_id)
+ self._cache[client_id] = _CacheEntry(
+ metadata=metadata,
+ expires_at=now + cache_seconds,
+ )
+ else:
+ self._cache.pop(client_id, None)
+ return metadata
+ finally:
+ lock_entry.users -= 1
+ if lock_entry.users == 0 and self._locks.get(client_id) is
lock_entry:
+ self._locks.pop(client_id, None)
+
+ async def _fetch_document(
+ self,
+ client_id: str,
+ ) -> tuple[dict[str, Any], Mapping[str, str]]:
+ hostname, port = validate_client_identifier_url(client_id)
+ addresses = await self._resolve_addresses(hostname, port)
+ resolver = _PinnedResolver(hostname, addresses)
+ connector = aiohttp.TCPConnector(
+ resolver=resolver,
+ use_dns_cache=False,
+ ttl_dns_cache=0,
+ )
+ timeout = aiohttp.ClientTimeout(total=self.timeout_seconds)
+ try:
+ async with aiohttp.ClientSession(
+ connector=connector,
+ timeout=timeout,
+ raise_for_status=False,
+ ) as session:
+ async with session.get(
+ client_id,
+ allow_redirects=False,
+ headers={"Accept": "application/json"},
+ ) as response:
+ if response.status != 200:
+ raise ClientMetadataError(
+ "Client metadata fetch did not return 200"
+ )
+ media_type = (
+ response.headers.get("Content-Type", "")
+ .split(";", 1)[0]
+ .strip()
+ .lower()
+ )
+ if media_type != "application/json" and not (
+ media_type.startswith("application/")
+ and media_type.endswith("+json")
+ ):
+ raise ClientMetadataError(
+ "Client metadata response is not JSON"
+ )
+ length = response.headers.get("Content-Length")
+ if length:
+ try:
+ if int(length) > self.max_document_bytes:
+ raise ClientMetadataError(
+ "Client metadata document is too large"
+ )
+ except ValueError as exc:
+ raise ClientMetadataError(
+ "Client metadata Content-Length is invalid"
+ ) from exc
+
+ body = bytearray()
+ async for chunk in response.content.iter_chunked(1024):
+ body.extend(chunk)
+ if len(body) > self.max_document_bytes:
+ raise ClientMetadataError(
+ "Client metadata document is too large"
+ )
+ try:
+ document = json.loads(body.decode("utf-8"))
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
+ raise ClientMetadataError(
+ "Client metadata document is invalid JSON"
+ ) from exc
+ if not isinstance(document, dict):
+ raise ClientMetadataError(
+ "Client metadata document must be a JSON object"
+ )
+ return document, dict(response.headers)
+ except ClientMetadataError:
+ raise
+ except (aiohttp.ClientError, TimeoutError, OSError) as exc:
+ raise ClientMetadataError("Client metadata fetch failed") from exc
+
+ async def _resolve_addresses(
+ self,
+ hostname: str,
+ port: int,
+ ) -> tuple[str, ...]:
+ try:
+ literal = ipaddress.ip_address(hostname)
+ except ValueError:
+ literal = None
+ addresses: tuple[str, ...]
+ if literal is not None:
+ addresses = (str(literal),)
+ else:
+ loop = asyncio.get_running_loop()
+ try:
+ results = await asyncio.wait_for(
+ loop.getaddrinfo(
+ hostname,
+ port,
+ family=socket.AF_UNSPEC,
+ type=socket.SOCK_STREAM,
+ ),
+ timeout=self.timeout_seconds,
+ )
+ except (OSError, TimeoutError) as exc:
+ raise ClientMetadataError(
+ "Client metadata hostname resolution failed"
+ ) from exc
+ addresses = tuple(
+ dict.fromkeys(result[4][0].split("%", 1)[0] for result in
results)
+ )
+ if not addresses or any(
+ not metadata_address_allowed(address, self.issuer) for address in
addresses
+ ):
+ raise ClientMetadataError(
+ "Client metadata hostname resolves to a prohibited address"
+ )
+ return addresses
+
+ def _validate_document(
+ self,
+ client_id: str,
+ document: dict[str, Any],
+ ) -> ResolvedClientMetadata:
+ if document.get("client_id") != client_id:
+ raise ClientMetadataError(
+ "Client metadata client_id does not match its URL"
+ )
+ client_name = document.get("client_name")
+ if (
+ not isinstance(client_name, str)
+ or not client_name.strip()
+ or len(client_name) > 200
+ or any(
+ ord(character) < 32 or ord(character) == 127
+ for character in client_name
+ )
+ ):
+ raise ClientMetadataError("Client metadata client_name is invalid")
+ if "client_secret" in document or "client_secret_expires_at" in
document:
+ raise ClientMetadataError(
+ "Client metadata must not contain a shared secret"
+ )
+ self._reject_private_jwk_material(document.get("jwks"))
+
+ token_auth_method = document.get("token_endpoint_auth_method", "none")
+ if token_auth_method != "none":
+ raise ClientMetadataError(
+ "Unsupported Client ID Metadata token authentication method"
+ )
+ grant_types = document.get("grant_types", ["authorization_code"])
+ if (
+ not isinstance(grant_types, list)
+ or not grant_types
+ or any(
+ not isinstance(grant, str)
+ or grant not in {"authorization_code", "refresh_token"}
+ for grant in grant_types
+ )
+ or "authorization_code" not in grant_types
+ or len(grant_types) != len(set(grant_types))
+ ):
+ raise ClientMetadataError("Client metadata grant_types are
invalid")
+ response_types = document.get("response_types", ["code"])
+ if response_types != ["code"]:
+ raise ClientMetadataError('Client metadata response_types must be
["code"]')
+
+ redirect_uris = document.get("redirect_uris")
+ if (
+ not isinstance(redirect_uris, list)
+ or not redirect_uris
+ or any(not isinstance(uri, str) for uri in redirect_uris)
+ or len(redirect_uris) != len(set(redirect_uris))
+ ):
+ raise ClientMetadataError("Client metadata redirect_uris are
invalid")
+ application_type = document.get("application_type")
+ if application_type is None:
+ application_type, validated_redirects =
self._infer_application_type(
+ redirect_uris
+ )
+ else:
+ if application_type not in {"native", "web"}:
+ raise ClientMetadataError("Client metadata application_type is
invalid")
+ validated_redirects = self._validate_redirects(
+ redirect_uris,
+ application_type,
+ )
+
+ requested_scope = document.get("scope")
+ if requested_scope is not None and not isinstance(requested_scope,
str):
+ raise ClientMetadataError("Client metadata scope is invalid")
+ try:
+ if requested_scope:
+ scopes = self.scope_policy.grant_client_scopes(
+ requested_scope,
+ explicit=True,
+ )
+ else:
+ scopes = tuple(sorted(self.scope_policy.server_allowed_scopes))
+ except TokenEndpointError as exc:
+ raise ClientMetadataError("Client metadata scope is invalid") from
exc
+
+ return ResolvedClientMetadata(
+ client_id=client_id,
+ client_name=client_name.strip(),
+ application_type=application_type,
+ redirect_uris=validated_redirects,
+ client_allowed_scopes=scopes,
+ token_endpoint_auth_method=token_auth_method,
+ )
+
+ def _infer_application_type(
+ self,
+ redirect_uris: list[str],
+ ) -> tuple[str, tuple[str, ...]]:
+ matches: list[tuple[str, tuple[str, ...]]] = []
+ for application_type in ("native", "web"):
+ try:
+ matches.append(
+ (
+ application_type,
+ self._validate_redirects(
+ redirect_uris,
+ application_type,
+ ),
+ )
+ )
+ except ClientMetadataError:
+ continue
+ if len(matches) != 1:
+ raise ClientMetadataError(
+ "Client metadata redirect_uris do not identify one application
type"
+ )
+ return matches[0]
+
+ def _validate_redirects(
+ self,
+ redirect_uris: list[str],
+ application_type: str,
+ ) -> tuple[str, ...]:
+ try:
+ return cast(
+ tuple[str, ...],
+ self.redirect_policy.validate_redirect_uris(
+ redirect_uris,
+ application_type=application_type,
+ source="client_id_metadata",
+ ),
+ )
+ except TokenEndpointError as exc:
+ raise ClientMetadataError(
+ "Client metadata redirect_uris are invalid"
+ ) from exc
+
+ def _reject_private_jwk_material(self, jwks: Any) -> None:
+ if jwks is None:
+ return
+ if not isinstance(jwks, dict) or not isinstance(jwks.get("keys"),
list):
+ raise ClientMetadataError("Client metadata jwks is invalid")
+ for key in jwks["keys"]:
+ if not isinstance(key, dict) or
_PRIVATE_JWK_PARAMETERS.intersection(key):
+ raise ClientMetadataError(
+ "Client metadata must not contain private key material"
+ )
+
+ def _cache_seconds(self, headers: Mapping[str, str]) -> int:
+ normalized = {str(key).lower(): str(value) for key, value in
headers.items()}
+ cache_control = normalized.get("cache-control", "")
+ directives = {
+ directive.strip().lower()
+ for directive in cache_control.split(",")
+ if directive.strip()
+ }
+ if "no-store" in directives or "no-cache" in directives:
+ return 0
+ match = _CACHE_MAX_AGE.search(cache_control)
+ if match:
+ max_age = int(match.group(1))
+ try:
+ age = max(0, int(normalized.get("age", "0")))
+ except ValueError:
+ age = 0
+ return min(max(0, max_age - age), self.max_cache_seconds)
+ return min(self.default_cache_seconds, self.max_cache_seconds)
+
+ def _make_cache_space(self, client_id: str) -> None:
+ now = time.monotonic()
+ self._cache = {
+ key: entry for key, entry in self._cache.items() if
entry.expires_at > now
+ }
+ if client_id in self._cache or len(self._cache) <
self.max_cache_entries:
+ return
+ oldest = min(
+ self._cache,
+ key=lambda key: self._cache[key].expires_at,
+ )
+ self._cache.pop(oldest, None)
diff --git a/doris_mcp_server/auth/doris_oauth_handlers.py
b/doris_mcp_server/auth/doris_oauth_handlers.py
index 5257a0c..d847152 100644
--- a/doris_mcp_server/auth/doris_oauth_handlers.py
+++ b/doris_mcp_server/auth/doris_oauth_handlers.py
@@ -4,13 +4,14 @@
import html
import ipaddress
import time
-from urllib.parse import urlencode
+from urllib.parse import urlencode, urlparse
from starlette.requests import Request
from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse,
Response
from starlette.routing import Route
from .doris_oauth_rate_limit import rate_limited_response
+from .doris_oauth_redirects import is_loopback_host
from .doris_oauth_types import (
AuthorizeError,
DorisOAuthError,
@@ -241,6 +242,7 @@ class DorisOAuthHandlers:
error_message: str = "",
) -> str:
title = "doris"
+ client_context_html = self._client_context_html(txn_id)
error_html = ""
if error_message:
error_html = (
@@ -303,6 +305,28 @@ class DorisOAuthHandlers:
color: var(--muted);
font-size: 14px;
}}
+ .client-context {{
+ margin: 0 0 20px;
+ padding: 12px;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ background: #fafafa;
+ font-size: 13px;
+ line-height: 1.5;
+ overflow-wrap: anywhere;
+ }}
+ .client-context strong,
+ .client-context span {{
+ display: block;
+ }}
+ .client-context span {{
+ color: var(--muted);
+ }}
+ .warning {{
+ margin-top: 8px;
+ color: #8a5a00;
+ font-weight: 600;
+ }}
label {{
display: block;
margin-bottom: 6px;
@@ -350,6 +374,7 @@ class DorisOAuthHandlers:
<div class="card">
<h2>{title}</h2>
<p class="subtitle">Doris account login</p>
+ {client_context_html}
{error_html}
<form method="POST" action="/doris-login">
<input type="hidden" name="txn_id" value="{html.escape(txn_id)}">
@@ -364,6 +389,40 @@ class DorisOAuthHandlers:
</body>
</html>"""
+ def _client_context_html(self, txn_id: str) -> str:
+ transaction = self.provider.get_login_transaction(txn_id)
+ if not transaction:
+ return ""
+ client = self.provider.store.get_client(transaction.client_id)
+ if not client:
+ return ""
+
+ client_url = urlparse(client.client_id)
+ client_host = client_url.hostname or client.client_id
+ redirect_url = urlparse(transaction.redirect_uri)
+ redirect_host = (
+ redirect_url.hostname
+ or redirect_url.netloc
+ or redirect_url.scheme
+ or transaction.redirect_uri
+ )
+ client_name = client.client_name or client.client_id
+ warning = ""
+ if is_loopback_host(redirect_url.hostname):
+ warning = (
+ '<span class="warning" role="note">'
+ "Local redirect: verify that you started this application."
+ "</span>"
+ )
+ return (
+ '<div class="client-context" aria-label="OAuth client details">'
+ f"<strong>{html.escape(client_name)}</strong>"
+ f"<span>Client host: {html.escape(client_host)}</span>"
+ f"<span>Redirect host: {html.escape(redirect_host)}</span>"
+ f"{warning}"
+ "</div>"
+ )
+
async def token(self, request: Request) -> JSONResponse:
ip = self._client_ip(request)
form = await request.form()
diff --git a/doris_mcp_server/auth/doris_oauth_provider.py
b/doris_mcp_server/auth/doris_oauth_provider.py
index 28bc2c2..1a05e13 100644
--- a/doris_mcp_server/auth/doris_oauth_provider.py
+++ b/doris_mcp_server/auth/doris_oauth_provider.py
@@ -16,6 +16,10 @@ from ..utils.security import (
AuthContext,
SecurityLevel,
)
+from .doris_oauth_client_metadata import (
+ ClientMetadataError,
+ DorisOAuthClientMetadataResolver,
+)
from .doris_oauth_rate_limit import DorisOAuthRateLimiter
from .doris_oauth_redirects import DorisOAuthRedirectPolicy, is_loopback_url
from .doris_oauth_scope_policy import DorisOAuthScopePolicy
@@ -50,6 +54,36 @@ class DorisOAuthProvider:
self.rate_limiter = DorisOAuthRateLimiter(
getattr(self.security_config,
"doris_oauth_rate_limit_window_seconds", 300)
)
+ self.client_metadata_resolver = DorisOAuthClientMetadataResolver(
+ issuer=self.issuer,
+ redirect_policy=self.redirect_policy,
+ scope_policy=self.scope_policy,
+ timeout_seconds=getattr(
+ self.security_config,
+ "doris_oauth_cimd_fetch_timeout_seconds",
+ 5,
+ ),
+ max_document_bytes=getattr(
+ self.security_config,
+ "doris_oauth_cimd_max_document_bytes",
+ 5120,
+ ),
+ default_cache_seconds=getattr(
+ self.security_config,
+ "doris_oauth_cimd_default_cache_seconds",
+ 300,
+ ),
+ max_cache_seconds=getattr(
+ self.security_config,
+ "doris_oauth_cimd_max_cache_seconds",
+ 3600,
+ ),
+ max_cache_entries=getattr(
+ self.security_config,
+ "doris_oauth_cimd_max_clients",
+ 1000,
+ ),
+ )
self.connection_manager = None
self._lock = asyncio.Lock()
self.logger = get_logger(__name__)
@@ -95,6 +129,7 @@ class DorisOAuthProvider:
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none",
"client_secret_post"],
"authorization_response_iss_parameter_supported": True,
+ "client_id_metadata_document_supported": True,
}
if self.dcr_enabled():
metadata["registration_endpoint"] = f"{self.issuer}/oauth/register"
@@ -187,11 +222,70 @@ class DorisOAuthProvider:
response["client_secret"] = client_secret
return response
+ async def _authorization_client(
+ self,
+ client_id: str,
+ *,
+ client_ip: str,
+ ):
+ client = self.store.get_client(client_id)
+ if client and client.source != "client_id_metadata":
+ return client
+ if not client_id.lower().startswith("https://"):
+ return None
+ try:
+ metadata = await self.client_metadata_resolver.resolve(client_id)
+ except ClientMetadataError:
+ return None
+
+ async with self._lock:
+ self.store.cleanup_expired()
+ existing = self.store.get_client(client_id)
+ discovered_clients = [
+ record
+ for record in self.store.clients_by_id.values()
+ if record.source == "client_id_metadata"
+ ]
+ maximum = getattr(
+ self.security_config,
+ "doris_oauth_cimd_max_clients",
+ 1000,
+ )
+ if existing is None and len(discovered_clients) >= maximum:
+ return None
+ lifetime = max(
+ getattr(
+ self.security_config,
+ "doris_oauth_refresh_token_expire_seconds",
+ 86400,
+ ),
+ getattr(
+ self.security_config,
+ "doris_oauth_auth_code_expire_seconds",
+ 300,
+ ),
+ )
+ return self.store.add_client(
+ client_id=metadata.client_id,
+ client_secret=None,
+ token_endpoint_auth_method=metadata.token_endpoint_auth_method,
+ application_type=metadata.application_type,
+ redirect_uris=metadata.redirect_uris,
+ client_allowed_scopes=metadata.client_allowed_scopes,
+ source="client_id_metadata",
+ expires_at=time.time() + lifetime,
+ registration_ip=client_ip,
+ client_name=metadata.client_name,
+ )
+
async def create_authorization_transaction(self, params: dict, *,
client_ip: str) -> str:
state = str(params.get("state") or "")
client_id = str(params.get("client_id") or "")
redirect_uri = params.get("redirect_uri")
- client = self.store.get_client(client_id)
+ client = await self._authorization_client(
+ client_id,
+ client_ip=client_ip,
+ )
if not client:
raise AuthorizeError("invalid_request", "Invalid client",
redirect_allowed=False)
diff --git a/doris_mcp_server/auth/doris_oauth_store.py
b/doris_mcp_server/auth/doris_oauth_store.py
index e2c0291..3910548 100644
--- a/doris_mcp_server/auth/doris_oauth_store.py
+++ b/doris_mcp_server/auth/doris_oauth_store.py
@@ -56,6 +56,7 @@ class DorisOAuthStore:
source: str,
expires_at: float | None,
registration_ip: str | None = None,
+ client_name: str | None = None,
) -> RegisteredClientRecord:
record = RegisteredClientRecord(
client_id=client_id,
@@ -68,6 +69,7 @@ class DorisOAuthStore:
created_at=time.time(),
expires_at=expires_at,
registration_ip_hash=self.hash_public_value(registration_ip) if
registration_ip else None,
+ client_name=client_name,
)
self.clients_by_id[client_id] = record
return record
diff --git a/doris_mcp_server/auth/doris_oauth_types.py
b/doris_mcp_server/auth/doris_oauth_types.py
index bff88b6..d312e72 100644
--- a/doris_mcp_server/auth/doris_oauth_types.py
+++ b/doris_mcp_server/auth/doris_oauth_types.py
@@ -102,6 +102,7 @@ class RegisteredClientRecord:
expires_at: float | None
last_used_at: float | None = None
registration_ip_hash: str | None = None
+ client_name: str | None = None
@dataclass
diff --git a/doris_mcp_server/utils/config.py b/doris_mcp_server/utils/config.py
index 2b66249..e49a6df 100644
--- a/doris_mcp_server/utils/config.py
+++ b/doris_mcp_server/utils/config.py
@@ -334,6 +334,11 @@ class SecurityConfig:
doris_oauth_login_page_title: str = "Doris MCP Login"
doris_oauth_allowed_redirect_uris: list[str] = field(default_factory=list)
doris_oauth_clients_file: str = ""
+ doris_oauth_cimd_fetch_timeout_seconds: int = 5
+ doris_oauth_cimd_max_document_bytes: int = 5120
+ doris_oauth_cimd_default_cache_seconds: int = 300
+ doris_oauth_cimd_max_cache_seconds: int = 3600
+ doris_oauth_cimd_max_clients: int = 1000
doris_oauth_dynamic_client_registration_mode: str = "auto"
enable_doris_oauth_production_dcr: bool = False
enable_doris_oauth_production_wildcard_redirects: bool = False
@@ -839,6 +844,18 @@ class DorisConfig:
"DORIS_OAUTH_CLIENTS_FILE",
config.security.doris_oauth_clients_file,
)
+ for env_name, field_name in {
+ "DORIS_OAUTH_CIMD_FETCH_TIMEOUT_SECONDS":
"doris_oauth_cimd_fetch_timeout_seconds",
+ "DORIS_OAUTH_CIMD_MAX_DOCUMENT_BYTES":
"doris_oauth_cimd_max_document_bytes",
+ "DORIS_OAUTH_CIMD_DEFAULT_CACHE_SECONDS":
"doris_oauth_cimd_default_cache_seconds",
+ "DORIS_OAUTH_CIMD_MAX_CACHE_SECONDS":
"doris_oauth_cimd_max_cache_seconds",
+ "DORIS_OAUTH_CIMD_MAX_CLIENTS": "doris_oauth_cimd_max_clients",
+ }.items():
+ setattr(
+ config.security,
+ field_name,
+ _env_int(env_name, getattr(config.security, field_name)),
+ )
config.security.doris_oauth_dynamic_client_registration_mode =
os.getenv(
"DORIS_OAUTH_DYNAMIC_CLIENT_REGISTRATION_MODE",
config.security.doris_oauth_dynamic_client_registration_mode,
@@ -1200,6 +1217,11 @@ class DorisConfig:
"doris_oauth_login_page_title":
self.security.doris_oauth_login_page_title,
"doris_oauth_allowed_redirect_uris":
self.security.doris_oauth_allowed_redirect_uris,
"doris_oauth_clients_file":
self.security.doris_oauth_clients_file,
+ "doris_oauth_cimd_fetch_timeout_seconds":
self.security.doris_oauth_cimd_fetch_timeout_seconds,
+ "doris_oauth_cimd_max_document_bytes":
self.security.doris_oauth_cimd_max_document_bytes,
+ "doris_oauth_cimd_default_cache_seconds":
self.security.doris_oauth_cimd_default_cache_seconds,
+ "doris_oauth_cimd_max_cache_seconds":
self.security.doris_oauth_cimd_max_cache_seconds,
+ "doris_oauth_cimd_max_clients":
self.security.doris_oauth_cimd_max_clients,
"doris_oauth_dynamic_client_registration_mode":
self.security.doris_oauth_dynamic_client_registration_mode,
"enable_doris_oauth_production_dcr":
self.security.enable_doris_oauth_production_dcr,
"enable_doris_oauth_production_wildcard_redirects":
self.security.enable_doris_oauth_production_wildcard_redirects,
@@ -1719,6 +1741,29 @@ def normalize_effective_auth_config(
if mode == "enabled" and not _is_loopback_url(base_url):
if not config.security.enable_doris_oauth_production_dcr:
raise AuthConfigError("Production Doris OAuth DCR requires
ENABLE_DORIS_OAUTH_PRODUCTION_DCR=true")
+ if not 1 <=
int(config.security.doris_oauth_cimd_fetch_timeout_seconds) <= 30:
+ raise AuthConfigError(
+ "doris_oauth_cimd_fetch_timeout_seconds must be between 1 and
30"
+ )
+ if not 1024 <=
int(config.security.doris_oauth_cimd_max_document_bytes) <= 65536:
+ raise AuthConfigError(
+ "doris_oauth_cimd_max_document_bytes must be between 1024 and
65536"
+ )
+ default_cache =
int(config.security.doris_oauth_cimd_default_cache_seconds)
+ max_cache = int(config.security.doris_oauth_cimd_max_cache_seconds)
+ if not 0 <= default_cache <= max_cache:
+ raise AuthConfigError(
+ "doris_oauth_cimd_default_cache_seconds must be between 0 and "
+ "doris_oauth_cimd_max_cache_seconds"
+ )
+ if not 1 <= max_cache <= 86400:
+ raise AuthConfigError(
+ "doris_oauth_cimd_max_cache_seconds must be between 1 and
86400"
+ )
+ if int(config.security.doris_oauth_cimd_max_clients) <= 0:
+ raise AuthConfigError(
+ "doris_oauth_cimd_max_clients must be greater than zero"
+ )
if config.security.doris_oauth_trust_proxy_headers and not
config.security.doris_oauth_trusted_proxy_cidrs:
raise AuthConfigError("Trusted proxy CIDRs are required when Doris
OAuth proxy headers are trusted")
diff --git a/test/auth/test_doris_oauth_client_metadata.py
b/test/auth/test_doris_oauth_client_metadata.py
new file mode 100644
index 0000000..bfb7fda
--- /dev/null
+++ b/test/auth/test_doris_oauth_client_metadata.py
@@ -0,0 +1,549 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Client ID Metadata Document conformance and integration tests."""
+
+from __future__ import annotations
+
+import asyncio
+import base64
+import hashlib
+import re
+from urllib.parse import parse_qs, urlparse
+
+import httpx
+import pytest
+from starlette.applications import Starlette
+
+import doris_mcp_server.auth.doris_oauth_client_metadata as
client_metadata_module
+from doris_mcp_server.auth.doris_oauth_client_metadata import (
+ ClientMetadataError,
+ DorisOAuthClientMetadataResolver,
+ metadata_address_allowed,
+ validate_client_identifier_url,
+)
+from doris_mcp_server.auth.doris_oauth_handlers import DorisOAuthHandlers
+from doris_mcp_server.auth.doris_oauth_provider import DorisOAuthProvider
+from doris_mcp_server.utils.config import (
+ DorisConfig,
+ _mark_source,
+ normalize_effective_auth_config,
+)
+
+CLIENT_ID = "https://client.example/oauth/client.json"
+REDIRECT_URI = "http://localhost:7777/callback"
+
+
+class FakeConnectionManager:
+ def __init__(self):
+ self.pools = {}
+ self.create_calls = []
+
+ async def create_or_replace_doris_user_pool(self, username, password):
+ self.create_calls.append((username, password))
+ if password != "correct":
+ raise RuntimeError("invalid credentials")
+ self.pools[username] = True
+
+ def has_doris_user_pool(self, username):
+ return self.pools.get(username, False)
+
+ async def cleanup_idle_doris_user_pools(self, active_users):
+ return None
+
+
+class FakeResponseContent:
+ def __init__(self, body):
+ self.body = body
+
+ async def iter_chunked(self, _size):
+ yield self.body
+
+
+class FakeMetadataResponse:
+ def __init__(self, *, status=200, headers=None, body=b"{}"):
+ self.status = status
+ self.headers = headers or {}
+ self.content = FakeResponseContent(body)
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *_args):
+ return None
+
+
+class FakeClientSession:
+ response = None
+ request = None
+
+ def __init__(self, *, connector, **_kwargs):
+ self.connector = connector
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *_args):
+ await self.connector.close()
+
+ def get(self, url, **kwargs):
+ type(self).request = (url, kwargs)
+ return type(self).response
+
+
+def _config() -> DorisConfig:
+ config = DorisConfig()
+ config.transport = "http"
+ _mark_source(config, "transport", "test")
+ config.security.enable_doris_oauth_auth = True
+ _mark_source(config, "enable_doris_oauth_auth", "test")
+ config.security.doris_oauth_base_url = "http://localhost:3000"
+ normalize_effective_auth_config(config, requested_workers=1)
+ return config
+
+
+def _document(client_id=CLIENT_ID, **overrides):
+ return {
+ "client_id": client_id,
+ "client_name": "Example MCP Client",
+ "redirect_uris": [REDIRECT_URI],
+ "grant_types": ["authorization_code", "refresh_token"],
+ "response_types": ["code"],
+ "token_endpoint_auth_method": "none",
+ **overrides,
+ }
+
+
+def _resolver(fetch_document):
+ provider = DorisOAuthProvider(_config())
+ return DorisOAuthClientMetadataResolver(
+ issuer=provider.issuer,
+ redirect_policy=provider.redirect_policy,
+ scope_policy=provider.scope_policy,
+ fetch_document=fetch_document,
+ )
+
+
+async def _client(app):
+ return httpx.AsyncClient(
+ transport=httpx.ASGITransport(app=app),
+ base_url="http://localhost:3000",
+ follow_redirects=False,
+ )
+
+
+def _pkce(verifier="cimd-verifier"):
+ digest = hashlib.sha256(verifier.encode("ascii")).digest()
+ return (
+ base64.urlsafe_b64encode(digest).decode("ascii").rstrip("="),
+ verifier,
+ )
+
+
[email protected](
+ "client_id",
+ [
+ "http://client.example/oauth/client.json",
+ "https://client.example",
+ "https://[email protected]/oauth/client.json",
+ "https://client.example/oauth/../client.json",
+ "https://client.example/oauth/%2e%2e/client.json",
+ "https://client.example/oauth/client.json#fragment",
+ r"https://client.example\@private.example/oauth/client.json",
+ ],
+)
+def test_client_identifier_url_rejects_nonconforming_values(client_id):
+ with pytest.raises(ClientMetadataError):
+ validate_client_identifier_url(client_id)
+
+
+def test_client_identifier_url_preserves_query_and_explicit_port():
+ hostname, port = validate_client_identifier_url(
+ "https://client.example:8443/oauth/client.json?v=1"
+ )
+
+ assert hostname == "client.example"
+ assert port == 8443
+
+
+def test_metadata_address_policy_blocks_special_use_addresses():
+ assert metadata_address_allowed("93.184.216.34", "https://as.example")
+ assert not metadata_address_allowed("10.0.0.10", "https://as.example")
+ assert not metadata_address_allowed("127.0.0.1", "https://as.example")
+ assert metadata_address_allowed("127.0.0.1", "http://localhost:3000")
+ assert not metadata_address_allowed("10.0.0.10", "http://localhost:3000")
+
+
[email protected]
+async def test_fetch_document_does_not_follow_redirects(monkeypatch):
+ resolver = _resolver(lambda _client_id: None)
+
+ async def resolve_addresses(_hostname, _port):
+ return ("93.184.216.34",)
+
+ monkeypatch.setattr(resolver, "_resolve_addresses", resolve_addresses)
+ FakeClientSession.response = FakeMetadataResponse(
+ status=302,
+ headers={"Location": "https://attacker.example/client.json"},
+ )
+ FakeClientSession.request = None
+ monkeypatch.setattr(
+ client_metadata_module.aiohttp,
+ "ClientSession",
+ FakeClientSession,
+ )
+
+ with pytest.raises(ClientMetadataError, match="did not return 200"):
+ await resolver._fetch_document(CLIENT_ID)
+
+ request_url, request_options = FakeClientSession.request
+ assert request_url == CLIENT_ID
+ assert request_options["allow_redirects"] is False
+
+
[email protected]
+async def test_fetch_document_enforces_streamed_size_limit(monkeypatch):
+ resolver = _resolver(lambda _client_id: None)
+
+ async def resolve_addresses(_hostname, _port):
+ return ("93.184.216.34",)
+
+ monkeypatch.setattr(resolver, "_resolve_addresses", resolve_addresses)
+ FakeClientSession.response = FakeMetadataResponse(
+ headers={"Content-Type": "application/json"},
+ body=b"x" * (resolver.max_document_bytes + 1),
+ )
+ monkeypatch.setattr(
+ client_metadata_module.aiohttp,
+ "ClientSession",
+ FakeClientSession,
+ )
+
+ with pytest.raises(ClientMetadataError, match="too large"):
+ await resolver._fetch_document(CLIENT_ID)
+
+
[email protected]
+async def test_resolver_validates_infers_native_and_respects_cache_headers():
+ calls = []
+
+ async def fetch_document(client_id):
+ calls.append(client_id)
+ return _document(client_id), {"Cache-Control": "public, max-age=60"}
+
+ resolver = _resolver(fetch_document)
+ first = await resolver.resolve(CLIENT_ID)
+ second = await resolver.resolve(CLIENT_ID)
+
+ assert first is second
+ assert first.client_name == "Example MCP Client"
+ assert first.application_type == "native"
+ assert first.redirect_uris == (REDIRECT_URI,)
+ assert calls == [CLIENT_ID]
+
+
[email protected]
+async def test_resolver_does_not_cache_no_store_response():
+ calls = []
+
+ async def fetch_document(client_id):
+ calls.append(client_id)
+ return _document(client_id), {"Cache-Control": "no-store"}
+
+ resolver = _resolver(fetch_document)
+ await resolver.resolve(CLIENT_ID)
+ await resolver.resolve(CLIENT_ID)
+
+ assert calls == [CLIENT_ID, CLIENT_ID]
+
+
[email protected]
+async def test_resolver_coalesces_concurrent_fetches_and_releases_lock():
+ calls = []
+
+ async def fetch_document(client_id):
+ calls.append(client_id)
+ await asyncio.sleep(0)
+ return _document(client_id), {"Cache-Control": "max-age=60"}
+
+ resolver = _resolver(fetch_document)
+ results = await asyncio.gather(
+ resolver.resolve(CLIENT_ID),
+ resolver.resolve(CLIENT_ID),
+ resolver.resolve(CLIENT_ID),
+ )
+
+ assert results[0] is results[1] is results[2]
+ assert calls == [CLIENT_ID]
+ assert resolver._locks == {}
+
+
[email protected]
+async def test_resolver_does_not_cache_invalid_document():
+ calls = []
+
+ async def fetch_document(client_id):
+ calls.append(client_id)
+ if len(calls) == 1:
+ return _document("https://other.example/oauth/client.json"), {}
+ return _document(client_id), {"Cache-Control": "max-age=60"}
+
+ resolver = _resolver(fetch_document)
+ with pytest.raises(ClientMetadataError):
+ await resolver.resolve(CLIENT_ID)
+
+ metadata = await resolver.resolve(CLIENT_ID)
+
+ assert metadata.client_id == CLIENT_ID
+ assert calls == [CLIENT_ID, CLIENT_ID]
+ assert resolver._locks == {}
+
+
[email protected](
+ "document",
+ [
+ _document("https://other.example/oauth/client.json"),
+ _document(client_name=""),
+ _document(client_name="Example\nClient"),
+ _document(redirect_uris=[]),
+ _document(client_secret="secret"),
+ _document(token_endpoint_auth_method="client_secret_post"),
+ _document(jwks={"keys": [{"kty": "RSA", "d": "private"}]}),
+ _document(response_types=["token"]),
+ _document(
+ redirect_uris=[
+ REDIRECT_URI,
+ "https://client.example/callback",
+ ]
+ ),
+ ],
+)
[email protected]
+async def test_resolver_rejects_invalid_or_unsafe_documents(document):
+ async def fetch_document(_client_id):
+ return document, {}
+
+ with pytest.raises(ClientMetadataError):
+ await _resolver(fetch_document).resolve(CLIENT_ID)
+
+
[email protected]
+async def
test_url_client_runs_full_authorization_code_flow_and_displays_hosts():
+ async def fetch_document(client_id):
+ return _document(
+ client_id,
+ client_name="<Example & Client>",
+ ), {"Cache-Control": "max-age=300"}
+
+ config = _config()
+ provider = DorisOAuthProvider(config)
+ provider.client_metadata_resolver = DorisOAuthClientMetadataResolver(
+ issuer=provider.issuer,
+ redirect_policy=provider.redirect_policy,
+ scope_policy=provider.scope_policy,
+ fetch_document=fetch_document,
+ )
+ connection_manager = FakeConnectionManager()
+ provider.configure_connection_manager(connection_manager)
+ app = Starlette(routes=DorisOAuthHandlers(provider).routes())
+ challenge, verifier = _pkce()
+
+ async with await _client(app) as client:
+ metadata_response = await
client.get("/.well-known/oauth-authorization-server")
+ assert
metadata_response.json()["client_id_metadata_document_supported"] is True
+ assert "registration_endpoint" in metadata_response.json()
+
+ authorize = await client.get(
+ "/oauth/authorize",
+ params={
+ "response_type": "code",
+ "client_id": CLIENT_ID,
+ "redirect_uri": REDIRECT_URI,
+ "state": "cimd-state",
+ "code_challenge": challenge,
+ "code_challenge_method": "S256",
+ "resource": provider.resource,
+ },
+ )
+ assert authorize.status_code == 302
+ assert authorize.headers["location"].startswith("/doris-login?")
+
+ login_get = await client.get(authorize.headers["location"])
+ assert login_get.status_code == 200
+ assert "<Example & Client>" in login_get.text
+ assert "<Example & Client>" not in login_get.text
+ assert "Client host: client.example" in login_get.text
+ assert "Redirect host: localhost" in login_get.text
+ assert "Local redirect:" in login_get.text
+ csrf = re.search(
+ r'name="login_csrf" value="([^"]+)"',
+ login_get.text,
+ ).group(1)
+ txn_id = re.search(
+ r'name="txn_id" value="([^"]+)"',
+ login_get.text,
+ ).group(1)
+
+ login_post = await client.post(
+ "/doris-login",
+ data={
+ "txn_id": txn_id,
+ "login_csrf": csrf,
+ "username": "alice",
+ "password": "correct",
+ },
+ )
+ authorization_response = parse_qs(
+ urlparse(login_post.headers["location"]).query
+ )
+ assert authorization_response["iss"] == [provider.issuer]
+ assert authorization_response["state"] == ["cimd-state"]
+
+ token_response = await client.post(
+ "/oauth/token",
+ data={
+ "grant_type": "authorization_code",
+ "client_id": CLIENT_ID,
+ "code": authorization_response["code"][0],
+ "redirect_uri": REDIRECT_URI,
+ "code_verifier": verifier,
+ "resource": provider.resource,
+ },
+ )
+
+ assert token_response.status_code == 200
+ assert token_response.json()["access_token"].startswith("doa_")
+ assert connection_manager.create_calls == [("alice", "correct")]
+ record = provider.store.get_client(CLIENT_ID)
+ assert record.source == "client_id_metadata"
+ assert record.client_name == "<Example & Client>"
+ assert record.application_type == "native"
+
+
[email protected]
+async def test_preconfigured_client_takes_precedence_over_url_discovery():
+ async def fetch_document(_client_id):
+ raise AssertionError("preconfigured client must not be fetched")
+
+ provider = DorisOAuthProvider(_config())
+ existing = provider.store.add_client(
+ client_id=CLIENT_ID,
+ client_secret=None,
+ token_endpoint_auth_method="none",
+ application_type="native",
+ redirect_uris=(REDIRECT_URI,),
+ client_allowed_scopes=tuple(
+ sorted(provider.scope_policy.server_allowed_scopes)
+ ),
+ source="preconfigured",
+ expires_at=None,
+ )
+ provider.client_metadata_resolver = DorisOAuthClientMetadataResolver(
+ issuer=provider.issuer,
+ redirect_policy=provider.redirect_policy,
+ scope_policy=provider.scope_policy,
+ fetch_document=fetch_document,
+ )
+
+ resolved = await provider._authorization_client(
+ CLIENT_ID,
+ client_ip="127.0.0.1",
+ )
+
+ assert resolved is existing
+
+
[email protected]
+async def test_untrusted_metadata_never_enables_redirect():
+ async def fetch_document(client_id):
+ return _document(client_id), {}
+
+ provider = DorisOAuthProvider(_config())
+ provider.client_metadata_resolver = DorisOAuthClientMetadataResolver(
+ issuer=provider.issuer,
+ redirect_policy=provider.redirect_policy,
+ scope_policy=provider.scope_policy,
+ fetch_document=fetch_document,
+ )
+ app = Starlette(routes=DorisOAuthHandlers(provider).routes())
+ challenge, _verifier = _pkce()
+
+ async with await _client(app) as client:
+ response = await client.get(
+ "/oauth/authorize",
+ params={
+ "response_type": "code",
+ "client_id": CLIENT_ID,
+ "redirect_uri": "https://attacker.example/callback",
+ "state": "state",
+ "code_challenge": challenge,
+ "code_challenge_method": "S256",
+ "resource": provider.resource,
+ },
+ )
+
+ assert response.status_code == 400
+ assert "location" not in response.headers
+
+
[email protected]
+async def test_metadata_fetch_failure_never_enables_redirect():
+ async def fetch_document(_client_id):
+ raise ClientMetadataError("unavailable")
+
+ provider = DorisOAuthProvider(_config())
+ provider.client_metadata_resolver = DorisOAuthClientMetadataResolver(
+ issuer=provider.issuer,
+ redirect_policy=provider.redirect_policy,
+ scope_policy=provider.scope_policy,
+ fetch_document=fetch_document,
+ )
+ app = Starlette(routes=DorisOAuthHandlers(provider).routes())
+ challenge, _verifier = _pkce()
+
+ async with await _client(app) as client:
+ response = await client.get(
+ "/oauth/authorize",
+ params={
+ "response_type": "code",
+ "client_id": CLIENT_ID,
+ "redirect_uri": REDIRECT_URI,
+ "state": "state",
+ "code_challenge": challenge,
+ "code_challenge_method": "S256",
+ "resource": provider.resource,
+ },
+ )
+
+ assert response.status_code == 400
+ assert "location" not in response.headers
+
+
[email protected]
+async def test_cimd_remains_advertised_when_dcr_is_disabled():
+ config = _config()
+ config.security.doris_oauth_dynamic_client_registration_mode = "disabled"
+ provider = DorisOAuthProvider(config)
+ app = Starlette(routes=DorisOAuthHandlers(provider).routes())
+
+ async with await _client(app) as client:
+ metadata_response = await
client.get("/.well-known/oauth-authorization-server")
+ registration_response = await client.post("/oauth/register", json={})
+
+ metadata = metadata_response.json()
+ assert metadata["client_id_metadata_document_supported"] is True
+ assert "registration_endpoint" not in metadata
+ assert registration_response.status_code == 404
diff --git a/test/auth/test_doris_oauth_routes.py
b/test/auth/test_doris_oauth_routes.py
index 625dcc7..e631cf4 100644
--- a/test/auth/test_doris_oauth_routes.py
+++ b/test/auth/test_doris_oauth_routes.py
@@ -130,6 +130,8 @@ async def
test_authorization_server_metadata_advertises_response_issuer():
metadata = response.json()
assert metadata["issuer"] == provider.issuer
assert metadata["authorization_response_iss_parameter_supported"] is True
+ assert metadata["client_id_metadata_document_supported"] is True
+ assert "registration_endpoint" in metadata
@pytest.mark.parametrize(
diff --git a/test/security/test_effective_auth_config.py
b/test/security/test_effective_auth_config.py
index e3b229c..849c173 100644
--- a/test/security/test_effective_auth_config.py
+++ b/test/security/test_effective_auth_config.py
@@ -154,6 +154,11 @@ def _doris_oauth_smoke_env_values():
"DORIS_OAUTH_EXPLAIN_TOOLS_ENABLED": "true",
"ENABLE_SECURITY_CHECK": "false",
"DORIS_OAUTH_DYNAMIC_CLIENT_REGISTRATION_MODE": "auto",
+ "DORIS_OAUTH_CIMD_FETCH_TIMEOUT_SECONDS": "7",
+ "DORIS_OAUTH_CIMD_MAX_DOCUMENT_BYTES": "6144",
+ "DORIS_OAUTH_CIMD_DEFAULT_CACHE_SECONDS": "120",
+ "DORIS_OAUTH_CIMD_MAX_CACHE_SECONDS": "900",
+ "DORIS_OAUTH_CIMD_MAX_CLIENTS": "42",
}
@@ -181,6 +186,11 @@ def
test_runtime_doris_oauth_smoke_env_matches_rbac_default_scope_model(monkeypa
"DORIS_OAUTH_EXPLAIN_TOOLS_ENABLED",
"ENABLE_SECURITY_CHECK",
"DORIS_OAUTH_DYNAMIC_CLIENT_REGISTRATION_MODE",
+ "DORIS_OAUTH_CIMD_FETCH_TIMEOUT_SECONDS",
+ "DORIS_OAUTH_CIMD_MAX_DOCUMENT_BYTES",
+ "DORIS_OAUTH_CIMD_DEFAULT_CACHE_SECONDS",
+ "DORIS_OAUTH_CIMD_MAX_CACHE_SECONDS",
+ "DORIS_OAUTH_CIMD_MAX_CLIENTS",
):
monkeypatch.setenv(key, env_values.get(key, ""))
monkeypatch.delenv("AUTH_TYPE", raising=False)
@@ -198,6 +208,11 @@ def
test_runtime_doris_oauth_smoke_env_matches_rbac_default_scope_model(monkeypa
assert config.security.enable_jwt_auth is False
assert config.security.enable_oauth_auth is False
assert config.security.oauth_enabled is False
+ assert config.security.doris_oauth_cimd_fetch_timeout_seconds == 7
+ assert config.security.doris_oauth_cimd_max_document_bytes == 6144
+ assert config.security.doris_oauth_cimd_default_cache_seconds == 120
+ assert config.security.doris_oauth_cimd_max_cache_seconds == 900
+ assert config.security.doris_oauth_cimd_max_clients == 42
assert {
"tool:list",
@@ -239,6 +254,63 @@ def test_doris_oauth_rejects_invalid_ttl():
normalize_effective_auth_config(config)
[email protected](
+ ("field_name", "value", "message"),
+ [
+ (
+ "doris_oauth_cimd_fetch_timeout_seconds",
+ 0,
+ "doris_oauth_cimd_fetch_timeout_seconds",
+ ),
+ (
+ "doris_oauth_cimd_max_document_bytes",
+ 1023,
+ "doris_oauth_cimd_max_document_bytes",
+ ),
+ (
+ "doris_oauth_cimd_default_cache_seconds",
+ -1,
+ "doris_oauth_cimd_default_cache_seconds",
+ ),
+ (
+ "doris_oauth_cimd_max_clients",
+ 0,
+ "doris_oauth_cimd_max_clients",
+ ),
+ ],
+)
+def test_doris_oauth_rejects_invalid_cimd_limits(field_name, value, message):
+ config = _doris_oauth_http_config()
+ setattr(config.security, field_name, value)
+
+ with pytest.raises(AuthConfigError, match=message):
+ normalize_effective_auth_config(config)
+
+
+def test_doris_oauth_rejects_cimd_default_cache_above_maximum():
+ config = _doris_oauth_http_config()
+ config.security.doris_oauth_cimd_default_cache_seconds = 61
+ config.security.doris_oauth_cimd_max_cache_seconds = 60
+
+ with pytest.raises(
+ AuthConfigError,
+ match="doris_oauth_cimd_default_cache_seconds",
+ ):
+ normalize_effective_auth_config(config)
+
+
+def test_doris_oauth_rejects_invalid_cimd_maximum_cache():
+ config = _doris_oauth_http_config()
+ config.security.doris_oauth_cimd_default_cache_seconds = 0
+ config.security.doris_oauth_cimd_max_cache_seconds = 0
+
+ with pytest.raises(
+ AuthConfigError,
+ match="doris_oauth_cimd_max_cache_seconds",
+ ):
+ normalize_effective_auth_config(config)
+
+
def test_doris_oauth_workers_zero_expands_before_fail_fast(monkeypatch):
config = DorisConfig()
config.transport = "http"
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]