This is an automated email from the ASF dual-hosted git repository.

FreeOnePlus pushed a commit to branch agent/sec-008-external-oauth-challenge
in repository https://gitbox.apache.org/repos/asf/doris-mcp-server.git

commit f83315467cb4b26ed3b1b6b87f32cc37a957944e
Author: FreeOnePlus <[email protected]>
AuthorDate: Wed Jul 29 21:16:25 2026 +0800

    fix: return external OAuth bearer challenges
---
 CHANGELOG.md                                    |   3 +
 README.md                                       |  10 ++
 doris_mcp_server/auth/mcp_auth_middleware.py    |  23 +++
 doris_mcp_server/auth/oauth_handlers.py         |  21 ++-
 doris_mcp_server/auth/oauth_provider.py         |  25 ++-
 doris_mcp_server/auth/oauth_resource.py         | 143 ++++++++++++++++
 doris_mcp_server/auth/oauth_token_validation.py |  14 +-
 doris_mcp_server/main.py                        |  10 ++
 doris_mcp_server/multiworker_app.py             |  22 ++-
 doris_mcp_server/utils/config.py                |  20 +++
 doris_mcp_server/utils/security.py              |   6 +
 test/auth/test_external_oauth_resource.py       | 199 ++++++++++++++++++++++
 test/auth/test_external_oauth_validation.py     |  14 +-
 test/security/test_bearer_credentials.py        |  32 ++++
 test/security/test_mcp_auth_middleware.py       | 216 +++++++++++++++++++++++-
 15 files changed, 743 insertions(+), 15 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7b37fd1..7817758 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -60,6 +60,9 @@ under **Unreleased** until a new version is selected and 
published.
 - Required external OAuth access tokens to pass trusted RFC 7662
   issuer/resource/audience, lifetime, and scope validation before userinfo is
   used.
+- Preserved external OAuth token failures through the authentication boundary
+  and returned RFC 6750/RFC 9728 Bearer challenges, protected-resource
+  metadata, and operation-specific insufficient-scope responses.
 - Released Doris connections on SQL profile, data freshness, and access
   analysis paths.
 - Improved Doris 4 role metadata compatibility and query recovery behavior.
diff --git a/README.md b/README.md
index b034dcd..d671925 100644
--- a/README.md
+++ b/README.md
@@ -663,6 +663,16 @@ Dedicated `OAUTH_INTROSPECTION_CLIENT_ID` and
 regular OAuth client credentials are used. Remote issuer, discovery,
 introspection, and userinfo URLs must use HTTPS.
 
+For HTTP deployments, the server publishes RFC 9728 metadata at
+`/.well-known/oauth-protected-resource`. Missing or invalid credentials receive
+an HTTP 401 Bearer challenge containing `resource_metadata` and the minimum
+configured scopes. A valid token that lacks an operation scope receives HTTP
+403 with `error="insufficient_scope"` and the exact scope required for
+step-up authorization. Access tokens and provider-internal error details are
+not copied into these responses. These HTTP OAuth challenges do not apply to
+stdio transport, where credentials are supplied through the local process
+environment.
+
 ### Doris-Backed OAuth Authentication
 
 Doris-backed OAuth is a separate OAuth mode where Doris itself is the 
authorization backend. The MCP client discovers this server's OAuth metadata, 
the user signs in with a Doris username and password, the server validates 
those credentials by creating a per-user Doris connection pool, and issued 
`doa_` access tokens route tool calls through that Doris user's pool. MCP 
scopes control which MCP operations can be called; Doris RBAC controls which 
catalogs, databases, tables, and metadata t [...]
diff --git a/doris_mcp_server/auth/mcp_auth_middleware.py 
b/doris_mcp_server/auth/mcp_auth_middleware.py
index fcf5140..631480a 100644
--- a/doris_mcp_server/auth/mcp_auth_middleware.py
+++ b/doris_mcp_server/auth/mcp_auth_middleware.py
@@ -35,6 +35,10 @@ from .doris_oauth_handlers import (
     insufficient_scope_response,
     protected_resource_error_response,
 )
+from .oauth_resource import (
+    external_oauth_error_response,
+    external_oauth_insufficient_scope_response,
+)
 from .operation_policy import OperationAuthorizationError
 
 ASGIApp = Callable[[dict[str, Any], Callable[..., Awaitable[Any]], 
Callable[..., Awaitable[Any]]], Awaitable[Any]]
@@ -66,6 +70,7 @@ class MCPAuthASGIMiddleware:
         self.effective_auth = effective_auth
 
     async def __call__(self, scope, receive, send):
+        credentials = None
         try:
             credentials = await extract_bearer_credentials_from_scope(scope)
             auth_context = await 
self.security_manager.authenticate_request(credentials)
@@ -75,6 +80,14 @@ class MCPAuthASGIMiddleware:
                     exc,
                     self.effective_auth.doris_oauth_base_url,
                 )
+            elif self.effective_auth.oauth_discovery_mode == "external_oauth":
+                response = external_oauth_error_response(
+                    exc,
+                    self.effective_auth,
+                    credentials_present=bool(
+                        credentials and credentials.is_bearer
+                    ),
+                )
             else:
                 response = JSONResponse(
                     {"error": "Authentication required", "message": str(exc)},
@@ -119,6 +132,16 @@ class MCPAuthASGIMiddleware:
                     exc.required_scope,
                     body,
                 )
+            elif (
+                self.effective_auth.oauth_discovery_mode == "external_oauth"
+                and exc.required_scope
+                and exc.error_code == "PERMISSION_DENIED"
+            ):
+                response = external_oauth_insufficient_scope_response(
+                    self.effective_auth,
+                    exc.required_scope,
+                    body,
+                )
             else:
                 response = JSONResponse(body, status_code=exc.status_code)
             await response(scope, receive, send)
diff --git a/doris_mcp_server/auth/oauth_handlers.py 
b/doris_mcp_server/auth/oauth_handlers.py
index 4b8a46d..57ce38c 100644
--- a/doris_mcp_server/auth/oauth_handlers.py
+++ b/doris_mcp_server/auth/oauth_handlers.py
@@ -27,7 +27,9 @@ import json
 from starlette.responses import JSONResponse, RedirectResponse, HTMLResponse
 from starlette.requests import Request
 
+from ..utils.config import get_effective_auth_config
 from ..utils.logger import get_logger
+from .oauth_resource import external_oauth_protected_resource_metadata
 
 logger = get_logger(__name__)
 
@@ -143,6 +145,23 @@ class OAuthHandlers:
                 {"error": f"Failed to get provider info: {str(e)}"},
                 status_code=500
             )
+
+    async def handle_protected_resource_metadata(
+        self,
+        request: Request,
+    ) -> JSONResponse:
+        """Return RFC 9728 metadata for external OAuth deployments."""
+        effective_auth = get_effective_auth_config(
+            self.security_manager.config
+        )
+        if not effective_auth.enable_external_oauth_auth:
+            return JSONResponse(
+                {"error": "external_oauth_disabled"},
+                status_code=404,
+            )
+        return JSONResponse(
+            external_oauth_protected_resource_metadata(effective_auth)
+        )
     
     async def handle_demo_page(self, request: Request) -> HTMLResponse:
         """Handle OAuth demo page
@@ -309,4 +328,4 @@ class OAuthHandlers:
 </html>
         """
         
-        return HTMLResponse(html_content)
\ No newline at end of file
+        return HTMLResponse(html_content)
diff --git a/doris_mcp_server/auth/oauth_provider.py 
b/doris_mcp_server/auth/oauth_provider.py
index 16b69d4..d1971ef 100644
--- a/doris_mcp_server/auth/oauth_provider.py
+++ b/doris_mcp_server/auth/oauth_provider.py
@@ -24,7 +24,10 @@ from typing import Dict, Any, Optional, Tuple
 from datetime import datetime
 
 from .oauth_client import OAuthClient
-from .oauth_token_validation import OAuthAccessTokenContext
+from .oauth_token_validation import (
+    OAuthAccessTokenContext,
+    OAuthAccessTokenValidationError,
+)
 from .oauth_types import OAuthTokens, OAuthUserInfo, OAuthState
 from ..utils.security import AuthContext, SecurityLevel
 from ..utils.logger import get_logger
@@ -119,9 +122,12 @@ class OAuthAuthenticationProvider:
             logger.info(f"OAuth authentication successful for user: 
{auth_context.user_id}")
             return auth_context
             
+        except OAuthAccessTokenValidationError:
+            logger.warning("OAuth callback access token was rejected")
+            raise
         except Exception as e:
             logger.error(f"OAuth callback handling failed: {e}")
-            raise ValueError(f"OAuth authentication failed: {str(e)}")
+            raise ValueError(f"OAuth authentication failed: {str(e)}") from e
     
     async def authenticate_with_token(self, access_token: str) -> AuthContext:
         """Authenticate using OAuth access token
@@ -157,9 +163,12 @@ class OAuthAuthenticationProvider:
             logger.info(f"OAuth token authentication successful for user: 
{auth_context.user_id}")
             return auth_context
             
+        except OAuthAccessTokenValidationError:
+            logger.warning("OAuth bearer access token was rejected")
+            raise
         except Exception as e:
             logger.error(f"OAuth token authentication failed: {e}")
-            raise ValueError(f"OAuth token authentication failed: {str(e)}")
+            raise ValueError(f"OAuth token authentication failed: {str(e)}") 
from e
     
     async def refresh_authentication(self, refresh_token: str) -> 
Tuple[AuthContext, str]:
         """Refresh OAuth authentication
@@ -195,9 +204,12 @@ class OAuthAuthenticationProvider:
             logger.info(f"OAuth refresh successful for user: 
{auth_context.user_id}")
             return auth_context, tokens.access_token
             
+        except OAuthAccessTokenValidationError:
+            logger.warning("OAuth refreshed access token was rejected")
+            raise
         except Exception as e:
             logger.error(f"OAuth refresh failed: {e}")
-            raise ValueError(f"OAuth refresh failed: {str(e)}")
+            raise ValueError(f"OAuth refresh failed: {str(e)}") from e
     
     async def _create_auth_context(
         self,
@@ -216,8 +228,9 @@ class OAuthAuthenticationProvider:
             AuthContext for the user
         """
         if user_info.sub != token_context.subject:
-            raise ValueError(
-                "OAuth userinfo subject does not match the access token"
+            raise OAuthAccessTokenValidationError(
+                "invalid_token",
+                "OAuth userinfo subject does not match the access token",
             )
 
         # Determine security level based on roles or email domain
diff --git a/doris_mcp_server/auth/oauth_resource.py 
b/doris_mcp_server/auth/oauth_resource.py
new file mode 100644
index 0000000..4e2170f
--- /dev/null
+++ b/doris_mcp_server/auth/oauth_resource.py
@@ -0,0 +1,143 @@
+# 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.
+"""OAuth protected-resource metadata and Bearer challenge helpers."""
+
+from urllib.parse import urlsplit, urlunsplit
+
+from starlette.responses import JSONResponse
+
+from ..utils.config import EffectiveAuthConfig
+from .oauth_token_validation import OAuthAccessTokenValidationError
+
+
+def external_oauth_resource_metadata_url(resource: str) -> str:
+    """Return the root RFC 9728 metadata URL served by this MCP server."""
+    parsed = urlsplit(resource)
+    if not parsed.scheme or not parsed.netloc:
+        raise ValueError("External OAuth resource must be an absolute URI")
+    return urlunsplit(
+        (
+            parsed.scheme,
+            parsed.netloc,
+            "/.well-known/oauth-protected-resource",
+            "",
+            "",
+        )
+    )
+
+
+def _quote_challenge_value(value: str) -> str:
+    """Escape one RFC 7235 quoted-string value and reject header injection."""
+    sanitized = str(value).replace("\r", "").replace("\n", "")
+    return sanitized.replace("\\", "\\\\").replace('"', '\\"')
+
+
+def bearer_challenge_header(
+    resource_metadata_url: str,
+    *,
+    error: str | None = None,
+    scopes: tuple[str, ...] = (),
+) -> str:
+    """Build an RFC 6750 Bearer challenge with RFC 9728 discovery metadata."""
+    parameters = [
+        f'resource_metadata="{_quote_challenge_value(resource_metadata_url)}"'
+    ]
+    if error:
+        parameters.append(f'error="{_quote_challenge_value(error)}"')
+    if scopes:
+        scope = " ".join(dict.fromkeys(scopes))
+        parameters.append(f'scope="{_quote_challenge_value(scope)}"')
+    return f"Bearer {', '.join(parameters)}"
+
+
+def external_oauth_protected_resource_metadata(
+    effective_auth: EffectiveAuthConfig,
+) -> dict[str, object]:
+    """Build the RFC 9728 document for an external authorization server."""
+    return {
+        "resource": effective_auth.external_oauth_resource,
+        "authorization_servers": [effective_auth.external_oauth_issuer],
+        "scopes_supported": list(effective_auth.external_oauth_scopes),
+        "bearer_methods_supported": ["header"],
+    }
+
+
+def external_oauth_error_response(
+    error: Exception,
+    effective_auth: EffectiveAuthConfig,
+    *,
+    credentials_present: bool,
+) -> JSONResponse:
+    """Map external OAuth authentication failures to OAuth resource errors."""
+    default_scopes = (
+        effective_auth.external_oauth_required_scopes
+        or effective_auth.external_oauth_scopes
+    )
+    challenge_error = "invalid_token" if credentials_present else None
+    status_code = 401
+    body = {
+        "error": "invalid_token" if credentials_present else 
"authentication_required",
+        "error_description": (
+            "Invalid access token" if credentials_present else "Authentication 
required"
+        ),
+    }
+
+    if isinstance(error, OAuthAccessTokenValidationError):
+        challenge_error = error.error
+        status_code = error.status_code
+        body = {
+            "error": error.error,
+            "error_description": error.description,
+        }
+        default_scopes = error.required_scopes or default_scopes
+
+    metadata_url = external_oauth_resource_metadata_url(
+        effective_auth.external_oauth_resource
+    )
+    return JSONResponse(
+        body,
+        status_code=status_code,
+        headers={
+            "WWW-Authenticate": bearer_challenge_header(
+                metadata_url,
+                error=challenge_error,
+                scopes=default_scopes,
+            )
+        },
+    )
+
+
+def external_oauth_insufficient_scope_response(
+    effective_auth: EffectiveAuthConfig,
+    required_scope: str,
+    body: dict[str, object],
+) -> JSONResponse:
+    """Return a standards-compliant runtime step-up challenge."""
+    metadata_url = external_oauth_resource_metadata_url(
+        effective_auth.external_oauth_resource
+    )
+    return JSONResponse(
+        body,
+        status_code=403,
+        headers={
+            "WWW-Authenticate": bearer_challenge_header(
+                metadata_url,
+                error="insufficient_scope",
+                scopes=(required_scope,),
+            )
+        },
+    )
diff --git a/doris_mcp_server/auth/oauth_token_validation.py 
b/doris_mcp_server/auth/oauth_token_validation.py
index e83e1af..9fec578 100644
--- a/doris_mcp_server/auth/oauth_token_validation.py
+++ b/doris_mcp_server/auth/oauth_token_validation.py
@@ -27,10 +27,18 @@ from typing import Any
 class OAuthAccessTokenValidationError(ValueError):
     """An external access token failed resource-server validation."""
 
-    def __init__(self, error: str, description: str):
+    def __init__(
+        self,
+        error: str,
+        description: str,
+        *,
+        required_scopes: Sequence[str] = (),
+    ):
         super().__init__(description)
         self.error = error
         self.description = description
+        self.required_scopes = tuple(dict.fromkeys(required_scopes))
+        self.status_code = 403 if error == "insufficient_scope" else 401
 
 
 @dataclass(frozen=True, slots=True)
@@ -153,12 +161,16 @@ class ExternalOAuthTokenValidator:
             raise OAuthAccessTokenValidationError(
                 "insufficient_scope",
                 "OAuth access token is missing a required scope",
+                required_scopes=tuple(sorted(self.required_scopes)),
             )
         granted_scopes = tuple(sorted(token_scopes & self.allowed_scopes))
         if not granted_scopes:
             raise OAuthAccessTokenValidationError(
                 "insufficient_scope",
                 "OAuth access token has no allowed scope",
+                required_scopes=tuple(
+                    sorted(self.required_scopes or self.allowed_scopes)
+                ),
             )
 
         subject = str(claims.get("sub") or "")
diff --git a/doris_mcp_server/main.py b/doris_mcp_server/main.py
index 2d8beaf..f53aca1 100644
--- a/doris_mcp_server/main.py
+++ b/doris_mcp_server/main.py
@@ -244,6 +244,11 @@ class DorisServer:
             
             async def oauth_provider_info(request):
                 return await oauth_handlers.handle_provider_info(request)
+
+            async def oauth_protected_resource_metadata(request):
+                return await oauth_handlers.handle_protected_resource_metadata(
+                    request
+                )
                 
             async def oauth_demo(request):
                 return await oauth_handlers.handle_demo_page(request)
@@ -291,6 +296,11 @@ class DorisServer:
             routes = [Route("/health", health_check, methods=["GET"])]
             if effective_auth.enable_external_oauth_auth:
                 routes.extend([
+                    Route(
+                        "/.well-known/oauth-protected-resource",
+                        oauth_protected_resource_metadata,
+                        methods=["GET"],
+                    ),
                     Route("/auth/login", oauth_login, methods=["GET"]),
                     Route("/auth/callback", oauth_callback, methods=["GET"]),
                     Route("/auth/provider", oauth_provider_info, 
methods=["GET"]),
diff --git a/doris_mcp_server/multiworker_app.py 
b/doris_mcp_server/multiworker_app.py
index 54b7418..89b924c 100644
--- a/doris_mcp_server/multiworker_app.py
+++ b/doris_mcp_server/multiworker_app.py
@@ -231,10 +231,20 @@ async def doris_oauth_unavailable(request):
     return JSONResponse({"error": "doris_oauth_not_initialized"}, 
status_code=503)
 
 
-async def doris_oauth_protected_resource_metadata(request):
-    if not _doris_oauth_handlers:
+async def oauth_protected_resource_metadata(request):
+    if _worker_effective_auth is None:
         return await doris_oauth_unavailable(request)
-    return await _doris_oauth_handlers.protected_resource_metadata(request)
+    if _worker_effective_auth and 
_worker_effective_auth.enable_doris_oauth_auth:
+        if not _doris_oauth_handlers:
+            return await doris_oauth_unavailable(request)
+        return await _doris_oauth_handlers.protected_resource_metadata(request)
+    if (
+        _worker_effective_auth
+        and _worker_effective_auth.enable_external_oauth_auth
+        and _oauth_handlers
+    ):
+        return await 
_oauth_handlers.handle_protected_resource_metadata(request)
+    return JSONResponse({"error": "oauth_disabled"}, status_code=404)
 
 
 async def doris_oauth_authorization_server_metadata(request):
@@ -395,7 +405,11 @@ basic_app = Starlette(
         Route("/auth/provider", oauth_provider_info, methods=["GET"]),
         Route("/auth/demo", oauth_demo, methods=["GET"]),
         # Doris OAuth endpoints are explicit to avoid top-level silent 404s.
-        Route("/.well-known/oauth-protected-resource", 
doris_oauth_protected_resource_metadata, methods=["GET"]),
+        Route(
+            "/.well-known/oauth-protected-resource",
+            oauth_protected_resource_metadata,
+            methods=["GET"],
+        ),
         Route("/.well-known/oauth-authorization-server", 
doris_oauth_authorization_server_metadata, methods=["GET"]),
         Route("/oauth/register", doris_oauth_register, methods=["POST"]),
         Route("/oauth/authorize", doris_oauth_authorize, methods=["GET"]),
diff --git a/doris_mcp_server/utils/config.py b/doris_mcp_server/utils/config.py
index 07dba2d..2b66249 100644
--- a/doris_mcp_server/utils/config.py
+++ b/doris_mcp_server/utils/config.py
@@ -101,6 +101,10 @@ class EffectiveAuthConfig:
     legacy_auth_type: str
     auth_config_warnings: tuple[str, ...] = ()
     doris_oauth_base_url: str = ""
+    external_oauth_issuer: str = ""
+    external_oauth_resource: str = ""
+    external_oauth_scopes: tuple[str, ...] = ()
+    external_oauth_required_scopes: tuple[str, ...] = ()
     source_summary: dict[str, str] = field(default_factory=dict)
 
 
@@ -1800,6 +1804,22 @@ def normalize_effective_auth_config(
         auth_methods=tuple(methods),
         oauth_discovery_mode=discovery_mode,
         doris_oauth_base_url=config.security.doris_oauth_base_url.rstrip("/"),
+        external_oauth_issuer=(
+            config.security.oauth_issuer if enable_external_oauth_auth else ""
+        ),
+        external_oauth_resource=(
+            config.security.oauth_resource if enable_external_oauth_auth else 
""
+        ),
+        external_oauth_scopes=(
+            tuple(config.security.oauth_scopes)
+            if enable_external_oauth_auth
+            else ()
+        ),
+        external_oauth_required_scopes=(
+            tuple(config.security.oauth_required_scopes)
+            if enable_external_oauth_auth
+            else ()
+        ),
         transport=transport,
         requested_workers=requested,
         effective_workers=effective_workers,
diff --git a/doris_mcp_server/utils/security.py 
b/doris_mcp_server/utils/security.py
index 3ffe093..d6acb27 100644
--- a/doris_mcp_server/utils/security.py
+++ b/doris_mcp_server/utils/security.py
@@ -319,6 +319,12 @@ class DorisSecurityManager:
                     raise
         
         # All enabled authentication methods failed
+        from ..auth.oauth_token_validation import (
+            OAuthAccessTokenValidationError,
+        )
+
+        if isinstance(last_error, OAuthAccessTokenValidationError):
+            raise last_error
         error_message = f"Authentication failed: {str(last_error)}" if 
last_error else "No authentication method succeeded"
         self.logger.warning(
             f"Authentication failed for client {credentials.client_ip}: 
{error_message}"
diff --git a/test/auth/test_external_oauth_resource.py 
b/test/auth/test_external_oauth_resource.py
new file mode 100644
index 0000000..e899d05
--- /dev/null
+++ b/test/auth/test_external_oauth_resource.py
@@ -0,0 +1,199 @@
+# 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.
+
+from types import SimpleNamespace
+
+import httpx
+import pytest
+from starlette.requests import Request
+
+import doris_mcp_server.multiworker_app as multiworker_app
+from doris_mcp_server.auth.oauth_handlers import OAuthHandlers
+from doris_mcp_server.auth.oauth_resource import (
+    bearer_challenge_header,
+    external_oauth_protected_resource_metadata,
+    external_oauth_resource_metadata_url,
+)
+from doris_mcp_server.auth.oauth_token_validation import (
+    OAuthAccessTokenValidationError,
+)
+from doris_mcp_server.utils.config import (
+    DorisConfig,
+    _mark_source,
+    normalize_effective_auth_config,
+)
+
+ISSUER = "https://issuer.example.test/tenant";
+RESOURCE = "https://mcp.example.test/mcp";
+
+
+def _external_oauth_config() -> DorisConfig:
+    config = DorisConfig()
+    config.security.enable_oauth_auth = True
+    _mark_source(config, "enable_oauth_auth", "test")
+    config.security.oauth_provider = "custom"
+    config.security.oauth_client_id = "oauth-client"
+    config.security.oauth_client_secret = "oauth-secret"
+    config.security.oauth_redirect_uri = "http://localhost:3000/auth/callback";
+    config.security.oauth_authorization_endpoint = f"{ISSUER}/authorize"
+    config.security.oauth_token_endpoint = f"{ISSUER}/token"
+    config.security.oauth_introspection_endpoint = f"{ISSUER}/introspect"
+    config.security.oauth_userinfo_endpoint = f"{ISSUER}/userinfo"
+    config.security.oauth_issuer = ISSUER
+    config.security.oauth_resource = RESOURCE
+    config.security.oauth_audience = RESOURCE
+    config.security.oauth_scopes = [
+        "tool:list",
+        "resource:list",
+        "resource:read",
+    ]
+    config.security.oauth_required_scopes = ["tool:list"]
+    normalize_effective_auth_config(config)
+    return config
+
+
+def test_external_oauth_metadata_matches_normalized_resource_context():
+    effective = _external_oauth_config().effective_auth
+
+    assert effective.external_oauth_issuer == ISSUER
+    assert effective.external_oauth_resource == RESOURCE
+    assert effective.external_oauth_scopes == (
+        "tool:list",
+        "resource:list",
+        "resource:read",
+    )
+    assert effective.external_oauth_required_scopes == ("tool:list",)
+    assert external_oauth_protected_resource_metadata(effective) == {
+        "resource": RESOURCE,
+        "authorization_servers": [ISSUER],
+        "scopes_supported": [
+            "tool:list",
+            "resource:list",
+            "resource:read",
+        ],
+        "bearer_methods_supported": ["header"],
+    }
+
+
+def test_external_oauth_metadata_url_uses_served_root_well_known_path():
+    assert external_oauth_resource_metadata_url(RESOURCE) == (
+        "https://mcp.example.test/.well-known/oauth-protected-resource";
+    )
+
+
+def test_bearer_challenge_escapes_quoted_values_and_header_injection():
+    challenge = bearer_challenge_header(
+        'https://mcp.example.test/.well-known/"metadata"\r\nInjected: yes',
+        error="invalid_token",
+        scopes=("tool:list", "tool:list", "resource:read"),
+    )
+
+    assert "\r" not in challenge
+    assert "\n" not in challenge
+    assert '\\"metadata\\"' in challenge
+    assert challenge.count("tool:list") == 1
+    assert 'scope="tool:list resource:read"' in challenge
+
+
[email protected]
+async def test_external_oauth_handler_serves_protected_resource_metadata():
+    config = _external_oauth_config()
+    handlers = OAuthHandlers(SimpleNamespace(config=config))
+
+    response = await handlers.handle_protected_resource_metadata(
+        Request({"type": "http", "method": "GET", "path": "/"})
+    )
+
+    assert response.status_code == 200
+    assert b'"resource":"https://mcp.example.test/mcp";' in response.body
+    assert b'"authorization_servers":["https://issuer.example.test/tenant";]' 
in (
+        response.body
+    )
+
+
[email protected]
+async def test_multiworker_dispatches_external_oauth_resource_metadata(
+    monkeypatch,
+):
+    config = _external_oauth_config()
+    handlers = OAuthHandlers(SimpleNamespace(config=config))
+    monkeypatch.setattr(
+        multiworker_app,
+        "_worker_effective_auth",
+        config.effective_auth,
+    )
+    monkeypatch.setattr(multiworker_app, "_oauth_handlers", handlers)
+    monkeypatch.setattr(multiworker_app, "_doris_oauth_handlers", None)
+
+    response = await multiworker_app.oauth_protected_resource_metadata(
+        Request({"type": "http", "method": "GET", "path": "/"})
+    )
+
+    assert response.status_code == 200
+    assert b'"resource":"https://mcp.example.test/mcp";' in response.body
+
+
[email protected]
+async def test_multiworker_http_routes_external_oauth_metadata_and_challenge(
+    monkeypatch,
+):
+    config = _external_oauth_config()
+    handlers = OAuthHandlers(SimpleNamespace(config=config))
+
+    class RejectingSecurityManager:
+        async def authenticate_request(self, credentials):
+            raise OAuthAccessTokenValidationError(
+                "invalid_token",
+                "OAuth access token has expired",
+            )
+
+    monkeypatch.setattr(multiworker_app, "_worker_initialized", True)
+    monkeypatch.setattr(
+        multiworker_app,
+        "_worker_effective_auth",
+        config.effective_auth,
+    )
+    monkeypatch.setattr(
+        multiworker_app,
+        "_worker_security_manager",
+        RejectingSecurityManager(),
+    )
+    monkeypatch.setattr(multiworker_app, "_oauth_handlers", handlers)
+    monkeypatch.setattr(multiworker_app, "_doris_oauth_handlers", None)
+
+    transport = httpx.ASGITransport(app=multiworker_app.app)
+    async with httpx.AsyncClient(
+        transport=transport,
+        base_url="https://mcp.example.test";,
+    ) as client:
+        metadata = await client.get("/.well-known/oauth-protected-resource")
+        rejected = await client.post(
+            "/mcp",
+            headers={"Authorization": "Bearer expired-token"},
+            json={"jsonrpc": "2.0", "id": 1, "method": "server/discover"},
+        )
+
+    assert metadata.status_code == 200
+    assert metadata.json()["resource"] == RESOURCE
+    assert rejected.status_code == 401
+    assert rejected.json()["error"] == "invalid_token"
+    challenge = rejected.headers["www-authenticate"]
+    assert 'error="invalid_token"' in challenge
+    assert 'scope="tool:list"' in challenge
+    assert "oauth-protected-resource" in challenge
+    assert "expired-token" not in rejected.text
+    assert "expired-token" not in challenge
diff --git a/test/auth/test_external_oauth_validation.py 
b/test/auth/test_external_oauth_validation.py
index 2b97a15..7af778e 100644
--- a/test/auth/test_external_oauth_validation.py
+++ b/test/auth/test_external_oauth_validation.py
@@ -134,9 +134,14 @@ async def test_failed_introspection_never_calls_userinfo():
     client = RejectingOAuthClient()
     provider = _provider(client)
 
-    with pytest.raises(ValueError, match="inactive"):
+    with pytest.raises(
+        OAuthAccessTokenValidationError,
+        match="inactive",
+    ) as exc_info:
         await provider.authenticate_with_token("rejected-access")
 
+    assert exc_info.value.error == "invalid_token"
+    assert exc_info.value.status_code == 401
     assert client.events == [("introspect", "rejected-access")]
 
 
@@ -148,5 +153,10 @@ async def 
test_userinfo_subject_must_match_introspected_subject():
     )
     provider = _provider(client)
 
-    with pytest.raises(ValueError, match="userinfo subject does not match"):
+    with pytest.raises(
+        OAuthAccessTokenValidationError,
+        match="userinfo subject does not match",
+    ) as exc_info:
         await provider.authenticate_with_token("access-1")
+
+    assert exc_info.value.error == "invalid_token"
diff --git a/test/security/test_bearer_credentials.py 
b/test/security/test_bearer_credentials.py
index d22ff2d..0dbda9b 100644
--- a/test/security/test_bearer_credentials.py
+++ b/test/security/test_bearer_credentials.py
@@ -21,6 +21,9 @@ from types import SimpleNamespace
 
 import pytest
 
+from doris_mcp_server.auth.oauth_token_validation import (
+    OAuthAccessTokenValidationError,
+)
 from doris_mcp_server.utils.auth_credentials import (
     BearerCredentials,
     normalize_bearer_credentials,
@@ -138,6 +141,35 @@ async def 
test_security_manager_passes_the_same_dto_to_every_provider(
     assert received[0][1] is credentials
 
 
[email protected]
+async def test_security_manager_preserves_external_oauth_challenge_error():
+    expected = OAuthAccessTokenValidationError(
+        "insufficient_scope",
+        "A required scope is missing",
+        required_scopes=("tool:call:exec_query",),
+    )
+
+    class Provider:
+        async def authenticate_oauth(self, credentials):
+            raise expected
+
+    manager = object.__new__(DorisSecurityManager)
+    manager.auth_provider = Provider()
+    manager.logger = logging.getLogger(__name__)
+    manager._get_effective_auth_config = lambda: SimpleNamespace(
+        auth_methods=("external_oauth",)
+    )
+
+    with pytest.raises(OAuthAccessTokenValidationError) as exc_info:
+        await manager.authenticate_request(
+            BearerCredentials(scheme="bearer", token="external-token")
+        )
+
+    assert exc_info.value is expected
+    assert exc_info.value.status_code == 403
+    assert exc_info.value.required_scopes == ("tool:call:exec_query",)
+
+
 @pytest.mark.asyncio
 async def test_static_token_provider_uses_canonical_credentials():
     token_info = SimpleNamespace(
diff --git a/test/security/test_mcp_auth_middleware.py 
b/test/security/test_mcp_auth_middleware.py
index c601b2d..45a8dc4 100644
--- a/test/security/test_mcp_auth_middleware.py
+++ b/test/security/test_mcp_auth_middleware.py
@@ -4,13 +4,25 @@ import pytest
 
 import doris_mcp_server.auth.mcp_auth_middleware as middleware_module
 from doris_mcp_server.auth.mcp_auth_middleware import MCPAuthASGIMiddleware
+from doris_mcp_server.auth.oauth_token_validation import (
+    OAuthAccessTokenValidationError,
+)
 from doris_mcp_server.auth.operation_policy import OperationAuthorizationError
 from doris_mcp_server.utils.auth_credentials import BearerCredentials
 from doris_mcp_server.utils.config import EffectiveAuthConfig
 from doris_mcp_server.utils.security import AuthContext, 
get_current_auth_context
 
 
-def _effective(auth_methods=("token",), discovery_mode="none", base_url=""):
+def _effective(
+    auth_methods=("token",),
+    discovery_mode="none",
+    base_url="",
+    *,
+    external_issuer="https://issuer.example.test";,
+    external_resource="https://mcp.example.test/mcp";,
+    external_scopes=("tool:list", "resource:read"),
+    external_required_scopes=("tool:list",),
+):
     return EffectiveAuthConfig(
         enable_token_auth="token" in auth_methods,
         enable_jwt_auth="jwt" in auth_methods,
@@ -23,6 +35,10 @@ def _effective(auth_methods=("token",), 
discovery_mode="none", base_url=""):
         requested_workers=1,
         effective_workers=1,
         legacy_auth_type="",
+        external_oauth_issuer=external_issuer,
+        external_oauth_resource=external_resource,
+        external_oauth_scopes=external_scopes,
+        external_oauth_required_scopes=external_required_scopes,
     )
 
 
@@ -135,6 +151,155 @@ async def 
test_mcp_auth_middleware_returns_doris_oauth_challenge_on_401():
     assert body["error"] == "authentication_required"
 
 
[email protected]
+async def test_external_oauth_missing_token_returns_discovery_challenge():
+    class SecurityManager:
+        async def authenticate_request(self, credentials):
+            assert credentials.is_bearer is False
+            raise ValueError("Missing external OAuth access token")
+
+    async def downstream(scope, receive, send):
+        raise AssertionError("downstream must not be called")
+
+    messages = []
+    middleware = MCPAuthASGIMiddleware(
+        SecurityManager(),
+        downstream,
+        _effective(
+            auth_methods=("external_oauth",),
+            discovery_mode="external_oauth",
+        ),
+    )
+    await middleware(
+        {
+            "type": "http",
+            "path": "/mcp",
+            "headers": [],
+            "client": ("127.0.0.1", 1),
+        },
+        _receive,
+        _send_collector(messages),
+    )
+
+    assert messages[0]["status"] == 401
+    challenge = dict(messages[0]["headers"])[b"www-authenticate"]
+    assert challenge.startswith(b"Bearer ")
+    assert (
+        b'resource_metadata="https://mcp.example.test/'
+        b'.well-known/oauth-protected-resource"' in challenge
+    )
+    assert b'scope="tool:list"' in challenge
+    assert b"error=" not in challenge
+    body = json.loads(messages[1]["body"])
+    assert body == {
+        "error": "authentication_required",
+        "error_description": "Authentication required",
+    }
+
+
[email protected]
[email protected](
+    ("oauth_error", "status_code", "required_scopes"),
+    [
+        ("invalid_token", 401, ()),
+        ("insufficient_scope", 403, ("tool:call:exec_query",)),
+    ],
+)
+async def test_external_oauth_validation_error_preserves_bearer_challenge(
+    oauth_error,
+    status_code,
+    required_scopes,
+):
+    secret = "must-not-leak"
+
+    class SecurityManager:
+        async def authenticate_request(self, credentials):
+            assert credentials.token == secret
+            raise OAuthAccessTokenValidationError(
+                oauth_error,
+                f"Controlled {oauth_error} description",
+                required_scopes=required_scopes,
+            )
+
+    async def downstream(scope, receive, send):
+        raise AssertionError("downstream must not be called")
+
+    messages = []
+    middleware = MCPAuthASGIMiddleware(
+        SecurityManager(),
+        downstream,
+        _effective(
+            auth_methods=("external_oauth",),
+            discovery_mode="external_oauth",
+        ),
+    )
+    await middleware(
+        {
+            "type": "http",
+            "path": "/mcp",
+            "headers": [
+                (b"authorization", f"Bearer {secret}".encode())
+            ],
+            "client": ("127.0.0.1", 1),
+        },
+        _receive,
+        _send_collector(messages),
+    )
+
+    assert messages[0]["status"] == status_code
+    challenge = dict(messages[0]["headers"])[b"www-authenticate"]
+    assert f'error="{oauth_error}"'.encode() in challenge
+    assert b"oauth-protected-resource" in challenge
+    if required_scopes:
+        assert b'scope="tool:call:exec_query"' in challenge
+    assert secret.encode() not in challenge
+    assert secret.encode() not in messages[1]["body"]
+    body = json.loads(messages[1]["body"])
+    assert body["error"] == oauth_error
+
+
[email protected]
+async def 
test_external_oauth_generic_failure_does_not_expose_provider_detail():
+    secret = "provider-reflected-secret"
+
+    class SecurityManager:
+        async def authenticate_request(self, credentials):
+            raise RuntimeError(f"introspection backend echoed {secret}")
+
+    async def downstream(scope, receive, send):
+        raise AssertionError("downstream must not be called")
+
+    messages = []
+    middleware = MCPAuthASGIMiddleware(
+        SecurityManager(),
+        downstream,
+        _effective(
+            auth_methods=("external_oauth",),
+            discovery_mode="external_oauth",
+        ),
+    )
+    await middleware(
+        {
+            "type": "http",
+            "path": "/mcp",
+            "headers": [(b"authorization", f"Bearer {secret}".encode())],
+            "client": ("127.0.0.1", 1),
+        },
+        _receive,
+        _send_collector(messages),
+    )
+
+    assert messages[0]["status"] == 401
+    challenge = dict(messages[0]["headers"])[b"www-authenticate"]
+    assert b'error="invalid_token"' in challenge
+    assert secret.encode() not in challenge
+    assert secret.encode() not in messages[1]["body"]
+    assert json.loads(messages[1]["body"]) == {
+        "error": "invalid_token",
+        "error_description": "Invalid access token",
+    }
+
+
 @pytest.mark.asyncio
 async def 
test_mcp_auth_middleware_returns_insufficient_scope_challenge_on_operation_denial():
     auth_context = AuthContext(
@@ -182,6 +347,55 @@ async def 
test_mcp_auth_middleware_returns_insufficient_scope_challenge_on_opera
     assert body["error"] == "PERMISSION_DENIED"
 
 
[email protected]
+async def test_external_oauth_operation_denial_returns_step_up_challenge():
+    auth_context = AuthContext(
+        token_id="t1",
+        user_id="alice",
+        auth_method="external_oauth",
+        oauth_scopes=["tool:list"],
+    )
+
+    class SecurityManager:
+        async def authenticate_request(self, credentials):
+            return auth_context
+
+    async def downstream(scope, receive, send):
+        raise OperationAuthorizationError(
+            "Missing required scope",
+            status_code=403,
+            error_code="PERMISSION_DENIED",
+            required_scope="tool:call:exec_query",
+            operation="tool:exec_query",
+        )
+
+    messages = []
+    middleware = MCPAuthASGIMiddleware(
+        SecurityManager(),
+        downstream,
+        _effective(
+            auth_methods=("external_oauth",),
+            discovery_mode="external_oauth",
+        ),
+    )
+    await middleware(
+        {
+            "type": "http",
+            "path": "/mcp",
+            "headers": [(b"authorization", b"Bearer external-token")],
+            "client": ("127.0.0.1", 1),
+        },
+        _receive,
+        _send_collector(messages),
+    )
+
+    assert messages[0]["status"] == 403
+    challenge = dict(messages[0]["headers"])[b"www-authenticate"]
+    assert b'error="insufficient_scope"' in challenge
+    assert b'scope="tool:call:exec_query"' in challenge
+    assert b"oauth-protected-resource" in challenge
+
+
 @pytest.mark.asyncio
 async def 
test_mcp_auth_middleware_resets_context_on_verify_failure(monkeypatch):
     auth_context = AuthContext(token_id="t1", user_id="u1", 
auth_method="token")


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to