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 "&lt;Example &amp; Client&gt;" 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]

Reply via email to