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 f78c00e  fix: enforce external OAuth token context (#116)
f78c00e is described below

commit f78c00e2e97658b0f4e0843b78222286fddfa1c3
Author: Yijia Su <[email protected]>
AuthorDate: Wed Jul 29 20:56:08 2026 +0800

    fix: enforce external OAuth token context (#116)
---
 .env.example                                    |  25 ++-
 CHANGELOG.md                                    |   3 +
 README.md                                       |  42 ++++++
 doris_mcp_server/auth/oauth_client.py           | 100 +++++++++++-
 doris_mcp_server/auth/oauth_provider.py         |  61 +++++++-
 doris_mcp_server/auth/oauth_token_validation.py | 180 ++++++++++++++++++++++
 doris_mcp_server/auth/oauth_types.py            |  10 +-
 doris_mcp_server/utils/config.py                | 175 +++++++++++++++++++++
 doris_mcp_server/utils/security.py              |   3 +
 test/auth/test_external_oauth_config.py         | 161 ++++++++++++++++++++
 test/auth/test_external_oauth_validation.py     | 152 +++++++++++++++++++
 test/auth/test_oauth_client_context.py          | 193 ++++++++++++++++++++++++
 test/auth/test_oauth_token_validation.py        | 148 ++++++++++++++++++
 13 files changed, 1237 insertions(+), 16 deletions(-)

diff --git a/.env.example b/.env.example
index dab0d15..d511b84 100644
--- a/.env.example
+++ b/.env.example
@@ -152,14 +152,31 @@ OAUTH_CLIENT_ID=your_oauth_client_id
 OAUTH_CLIENT_SECRET=your_oauth_client_secret
 OAUTH_REDIRECT_URI=http://localhost:3000/auth/callback
 
-# OAuth endpoints (for generic provider)
+# External OAuth trust boundary. The server validates every access token 
through
+# RFC 7662 introspection before calling userinfo. OAUTH_ISSUER and
+# OAUTH_RESOURCE are mandatory. OAUTH_AUDIENCE defaults to OAUTH_RESOURCE.
+OAUTH_ISSUER=https://your-provider.com
+OAUTH_RESOURCE=https://your-mcp-server.example.com/mcp
+OAUTH_AUDIENCE=https://your-mcp-server.example.com/mcp
+
+# OAuth endpoints (for generic provider). OAUTH_DISCOVERY_URL may supply the
+# endpoints, but its returned issuer must exactly match OAUTH_ISSUER.
+OAUTH_DISCOVERY_URL=https://your-provider.com/.well-known/oauth-authorization-server
 OAUTH_AUTHORIZATION_URL=https://your-provider.com/auth
 OAUTH_TOKEN_URL=https://your-provider.com/token
+OAUTH_INTROSPECTION_URL=https://your-provider.com/introspect
 OAUTH_USERINFO_URL=https://your-provider.com/userinfo
 OAUTH_JWKS_URL=https://your-provider.com/.well-known/jwks.json
 
-# OAuth scope and claims
+# Optional dedicated RFC 7662 credentials. If omitted, OAUTH_CLIENT_ID and
+# OAUTH_CLIENT_SECRET are used.
+# OAUTH_INTROSPECTION_CLIENT_ID=your_introspection_client_id
+# OAUTH_INTROSPECTION_CLIENT_SECRET=your_introspection_client_secret
+
+# OAuth scope and claims. OAUTH_REQUIRED_SCOPE defaults to every value in
+# OAUTH_SCOPE. Token scopes outside OAUTH_SCOPE are not copied into 
AuthContext.
 OAUTH_SCOPE=openid profile email
+OAUTH_REQUIRED_SCOPE=openid profile email
 OAUTH_USER_ID_CLAIM=sub
 OAUTH_USERNAME_CLAIM=preferred_username
 OAUTH_EMAIL_CLAIM=email
@@ -171,7 +188,9 @@ OAUTH_SESSION_SECRET=your_oauth_session_secret_here
 OAUTH_SESSION_EXPIRY=3600
 OAUTH_STATE_EXPIRY=300
 
-# Popular OAuth providers presets (uncomment and configure as needed)
+# Popular OAuth provider endpoint examples. External OAuth is enabled only when
+# that authorization server also provides a trusted RFC 7662 introspection
+# endpoint and the issuer/resource/audience values above are configured.
 
 # Google OAuth Configuration
 # OAUTH_PROVIDER_TYPE=google
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ceca727..7b37fd1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -57,6 +57,9 @@ under **Unreleased** until a new version is selected and 
published.
 - Normalized bearer credentials once at the MCP authentication boundary and
   passed the same redacted DTO to static token, JWT, external OAuth, and Doris
   OAuth providers.
+- Required external OAuth access tokens to pass trusted RFC 7662
+  issuer/resource/audience, lifetime, and scope validation before userinfo is
+  used.
 - 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 6523ce4..b034dcd 100644
--- a/README.md
+++ b/README.md
@@ -269,6 +269,11 @@ cp .env.example .env
     *   `ENABLE_TOKEN_AUTH`: Enable token-based authentication (default: false)
     *   `ENABLE_JWT_AUTH`: Enable JWT authentication (default: false)
     *   `ENABLE_OAUTH_AUTH`: Enable OAuth authentication (default: false)
+    *   `OAUTH_ISSUER`: Exact external authorization-server issuer
+    *   `OAUTH_RESOURCE`: Canonical MCP protected-resource URI
+    *   `OAUTH_AUDIENCE`: Expected access-token audience (defaults to 
`OAUTH_RESOURCE`)
+    *   `OAUTH_INTROSPECTION_URL`: Trusted RFC 7662 token-introspection 
endpoint
+    *   `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
     *   `TOKEN_FILE_PATH`: Path to tokens.json file for token management 
(default: tokens.json)
@@ -621,6 +626,43 @@ The repository ships no usable static token or legacy 
secret. Startup fails
 when static token authentication is enabled without at least one active,
 high-entropy `TOKEN_<ID>` value or an equivalent entry in `TOKEN_FILE_PATH`.
 
+### External OAuth/OIDC Access Token Validation
+
+External OAuth is fail-closed. Before the server requests user information, it
+uses a trusted RFC 7662 introspection endpoint and requires an active,
+unexpired token with the exact configured issuer, expected audience, MCP
+resource binding, required scopes, and a subject. A successful userinfo request
+is not accepted as proof that a token was issued for this MCP server. The
+userinfo subject must also match the introspected token subject.
+
+`OAUTH_RESOURCE` is sent on authorization-code and refresh-token requests.
+`OAUTH_AUDIENCE` defaults to that resource URI. `OAUTH_REQUIRED_SCOPE` defaults
+to all scopes in `OAUTH_SCOPE`; scopes returned by the authorization server but
+not present in `OAUTH_SCOPE` are excluded from the authentication context.
+
+```bash
+ENABLE_OAUTH_AUTH=true
+OAUTH_CLIENT_ID=your_oauth_client_id
+OAUTH_CLIENT_SECRET=your_oauth_client_secret
+OAUTH_REDIRECT_URI=https://mcp.example.com/auth/callback
+
+OAUTH_ISSUER=https://issuer.example.com
+OAUTH_RESOURCE=https://mcp.example.com/mcp
+OAUTH_AUDIENCE=https://mcp.example.com/mcp
+OAUTH_INTROSPECTION_URL=https://issuer.example.com/introspect
+OAUTH_USERINFO_URL=https://issuer.example.com/userinfo
+
+OAUTH_SCOPE="tool:list resource:list resource:read"
+OAUTH_REQUIRED_SCOPE="tool:list resource:read"
+```
+
+An authorization-server discovery document may provide the introspection and
+userinfo endpoints, but its `issuer` must exactly match `OAUTH_ISSUER`.
+Dedicated `OAUTH_INTROSPECTION_CLIENT_ID` and
+`OAUTH_INTROSPECTION_CLIENT_SECRET` values may be configured; otherwise the
+regular OAuth client credentials are used. Remote issuer, discovery,
+introspection, and userinfo URLs must use HTTPS.
+
 ### 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/oauth_client.py 
b/doris_mcp_server/auth/oauth_client.py
index 8b8632f..3e60fc5 100644
--- a/doris_mcp_server/auth/oauth_client.py
+++ b/doris_mcp_server/auth/oauth_client.py
@@ -39,6 +39,11 @@ from .oauth_types import (
     OAuthProvider, OAuthState, OAuthTokens, OAuthUserInfo, 
     OIDCDiscovery, OAuthError, OAuthProviderConfig, OAUTH_PROVIDERS
 )
+from .oauth_token_validation import (
+    OAuthAccessTokenValidationError,
+    ExternalOAuthTokenValidator,
+    OAuthAccessTokenContext,
+)
 from ..utils.logger import get_logger
 
 logger = get_logger(__name__)
@@ -194,6 +199,13 @@ class OAuthClient:
             
         # Build provider configuration
         self.provider_config = self._build_provider_config(security_config)
+        self.token_validator = ExternalOAuthTokenValidator(
+            issuer=self.provider_config.issuer,
+            resource=self.provider_config.resource,
+            audience=self.provider_config.audience,
+            allowed_scopes=self.provider_config.scopes,
+            required_scopes=self.provider_config.required_scopes,
+        )
         self.state_manager = 
OAuthStateManager(security_config.oauth_state_expiry)
         
         # HTTP client session
@@ -221,17 +233,29 @@ class OAuthClient:
         
         # Get default configuration for known providers
         defaults = OAUTH_PROVIDERS.get(provider, {})
+        scopes = security_config.oauth_scopes or defaults.get(
+            "scopes",
+            ["openid", "email", "profile"],
+        )
+        required_scopes = security_config.oauth_required_scopes or scopes
         
         return OAuthProviderConfig(
             provider=provider,
             client_id=security_config.oauth_client_id,
             client_secret=security_config.oauth_client_secret,
             redirect_uri=security_config.oauth_redirect_uri,
-            scopes=security_config.oauth_scopes or defaults.get("scopes", 
["openid", "email", "profile"]),
+            scopes=scopes,
+            required_scopes=required_scopes,
+            issuer=security_config.oauth_issuer,
+            resource=security_config.oauth_resource,
+            audience=security_config.oauth_audience,
             
             # Endpoints (use configured or defaults)
             
authorization_endpoint=security_config.oauth_authorization_endpoint or 
defaults.get("authorization_endpoint", ""),
             token_endpoint=security_config.oauth_token_endpoint or 
defaults.get("token_endpoint", ""),
+            
introspection_endpoint=security_config.oauth_introspection_endpoint,
+            
introspection_client_id=security_config.oauth_introspection_client_id,
+            
introspection_client_secret=security_config.oauth_introspection_client_secret,
             userinfo_endpoint=security_config.oauth_userinfo_endpoint or 
defaults.get("userinfo_endpoint"),
             jwks_uri=security_config.oauth_jwks_uri or 
defaults.get("jwks_uri"),
             
@@ -269,12 +293,20 @@ class OAuthClient:
             # Perform OIDC discovery if configured
             if self.provider_config.discovery_url:
                 await self._discover_oidc_endpoints()
+
+            if not self.provider_config.introspection_endpoint:
+                raise ValueError(
+                    "OAuth introspection endpoint is not configured"
+                )
+            if not self.provider_config.userinfo_endpoint:
+                raise ValueError("OAuth userinfo endpoint is not configured")
             
             logger.info("OAuth client initialization completed")
             return True
             
         except Exception as e:
             logger.error(f"Failed to initialize OAuth client: {e}")
+            await self.shutdown()
             return False
     
     async def shutdown(self):
@@ -308,11 +340,18 @@ class OAuthClient:
             async with self._session.get(self.provider_config.discovery_url) 
as response:
                 response.raise_for_status()
                 data = await response.json()
+
+            discovered_issuer = str(data.get("issuer") or "")
+            if discovered_issuer != self.provider_config.issuer:
+                raise ValueError(
+                    "OIDC discovery issuer does not match OAUTH_ISSUER"
+                )
             
             discovery = OIDCDiscovery(
-                issuer=data["issuer"],
+                issuer=discovered_issuer,
                 authorization_endpoint=data["authorization_endpoint"],
                 token_endpoint=data["token_endpoint"],
+                introspection_endpoint=data.get("introspection_endpoint"),
                 userinfo_endpoint=data.get("userinfo_endpoint"),
                 jwks_uri=data.get("jwks_uri"),
                 scopes_supported=data.get("scopes_supported"),
@@ -326,6 +365,10 @@ class OAuthClient:
                 self.provider_config.authorization_endpoint = 
discovery.authorization_endpoint
             if not self.provider_config.token_endpoint:
                 self.provider_config.token_endpoint = discovery.token_endpoint
+            if not self.provider_config.introspection_endpoint:
+                self.provider_config.introspection_endpoint = (
+                    discovery.introspection_endpoint
+                )
             if not self.provider_config.userinfo_endpoint:
                 self.provider_config.userinfo_endpoint = 
discovery.userinfo_endpoint
             if not self.provider_config.jwks_uri:
@@ -364,7 +407,8 @@ class OAuthClient:
             'client_id': self.provider_config.client_id,
             'redirect_uri': self.provider_config.redirect_uri,
             'scope': ' '.join(self.provider_config.scopes),
-            'state': oauth_state.state
+            'state': oauth_state.state,
+            'resource': self.provider_config.resource,
         }
         
         # Add PKCE challenge
@@ -410,7 +454,8 @@ class OAuthClient:
                 'client_id': self.provider_config.client_id,
                 'client_secret': self.provider_config.client_secret,
                 'code': code,
-                'redirect_uri': oauth_state.redirect_uri
+                'redirect_uri': oauth_state.redirect_uri,
+                'resource': self.provider_config.resource,
             }
             
             # Add PKCE verifier
@@ -444,6 +489,50 @@ class OAuthClient:
         except Exception as e:
             logger.error(f"Token exchange failed: {e}")
             raise ValueError(f"Token exchange failed: {str(e)}")
+
+    async def introspect_access_token(
+        self,
+        access_token: str,
+    ) -> OAuthAccessTokenContext:
+        """Validate an access token with the trusted authorization server."""
+        if not self.enabled:
+            raise ValueError("OAuth client is not enabled")
+        if not self._session:
+            raise ValueError("OAuth client is not initialized")
+        endpoint = self.provider_config.introspection_endpoint
+        if not endpoint:
+            raise ValueError("OAuth introspection endpoint is not configured")
+
+        auth = aiohttp.BasicAuth(
+            self.provider_config.introspection_client_id,
+            self.provider_config.introspection_client_secret,
+        )
+        try:
+            async with self._session.post(
+                endpoint,
+                data={
+                    "token": access_token,
+                    "token_type_hint": "access_token",
+                },
+                auth=auth,
+                headers={
+                    "Accept": "application/json",
+                    "Content-Type": "application/x-www-form-urlencoded",
+                },
+            ) as response:
+                if response.status != 200:
+                    raise ValueError(
+                        "OAuth token introspection request failed"
+                    )
+                claims = await response.json()
+            return self.token_validator.validate(claims)
+        except OAuthAccessTokenValidationError:
+            raise
+        except Exception as e:
+            logger.error(f"OAuth token introspection failed: {e}")
+            raise ValueError(
+                f"OAuth token introspection failed: {str(e)}"
+            ) from e
     
     async def get_user_info(self, tokens: OAuthTokens) -> OAuthUserInfo:
         """Get user information from OAuth provider
@@ -509,7 +598,8 @@ class OAuthClient:
                 'grant_type': 'refresh_token',
                 'client_id': self.provider_config.client_id,
                 'client_secret': self.provider_config.client_secret,
-                'refresh_token': refresh_token
+                'refresh_token': refresh_token,
+                'resource': self.provider_config.resource,
             }
             
             async with self._session.post(
diff --git a/doris_mcp_server/auth/oauth_provider.py 
b/doris_mcp_server/auth/oauth_provider.py
index 9d4586e..16b69d4 100644
--- a/doris_mcp_server/auth/oauth_provider.py
+++ b/doris_mcp_server/auth/oauth_provider.py
@@ -24,6 +24,7 @@ from typing import Dict, Any, Optional, Tuple
 from datetime import datetime
 
 from .oauth_client import OAuthClient
+from .oauth_token_validation import OAuthAccessTokenContext
 from .oauth_types import OAuthTokens, OAuthUserInfo, OAuthState
 from ..utils.security import AuthContext, SecurityLevel
 from ..utils.logger import get_logger
@@ -99,12 +100,21 @@ class OAuthAuthenticationProvider:
         try:
             # Exchange code for tokens
             tokens, oauth_state = await 
self.oauth_client.exchange_code_for_tokens(code, state)
+
+            # Validate the access token for this issuer and MCP resource
+            token_context = await self.oauth_client.introspect_access_token(
+                tokens.access_token
+            )
             
             # Get user information
             user_info = await self.oauth_client.get_user_info(tokens)
             
             # Create authentication context
-            auth_context = await self._create_auth_context(user_info, tokens)
+            auth_context = await self._create_auth_context(
+                user_info,
+                tokens,
+                token_context,
+            )
             
             logger.info(f"OAuth authentication successful for user: 
{auth_context.user_id}")
             return auth_context
@@ -128,12 +138,21 @@ class OAuthAuthenticationProvider:
         try:
             # Create token object
             tokens = OAuthTokens(access_token=access_token)
+
+            # Validate the access token before using identity information
+            token_context = await self.oauth_client.introspect_access_token(
+                access_token
+            )
             
             # Get user information
             user_info = await self.oauth_client.get_user_info(tokens)
             
             # Create authentication context
-            auth_context = await self._create_auth_context(user_info, tokens)
+            auth_context = await self._create_auth_context(
+                user_info,
+                tokens,
+                token_context,
+            )
             
             logger.info(f"OAuth token authentication successful for user: 
{auth_context.user_id}")
             return auth_context
@@ -157,12 +176,21 @@ class OAuthAuthenticationProvider:
         try:
             # Refresh tokens
             tokens = await self.oauth_client.refresh_tokens(refresh_token)
+
+            # Validate the replacement token before using it
+            token_context = await self.oauth_client.introspect_access_token(
+                tokens.access_token
+            )
             
             # Get updated user information
             user_info = await self.oauth_client.get_user_info(tokens)
             
             # Create authentication context
-            auth_context = await self._create_auth_context(user_info, tokens)
+            auth_context = await self._create_auth_context(
+                user_info,
+                tokens,
+                token_context,
+            )
             
             logger.info(f"OAuth refresh successful for user: 
{auth_context.user_id}")
             return auth_context, tokens.access_token
@@ -171,16 +199,27 @@ class OAuthAuthenticationProvider:
             logger.error(f"OAuth refresh failed: {e}")
             raise ValueError(f"OAuth refresh failed: {str(e)}")
     
-    async def _create_auth_context(self, user_info: OAuthUserInfo, tokens: 
OAuthTokens) -> AuthContext:
+    async def _create_auth_context(
+        self,
+        user_info: OAuthUserInfo,
+        tokens: OAuthTokens,
+        token_context: OAuthAccessTokenContext,
+    ) -> AuthContext:
         """Create authentication context from OAuth user info
         
         Args:
             user_info: OAuth user information
             tokens: OAuth tokens
+            token_context: Validated access-token claims
             
         Returns:
             AuthContext for the user
         """
+        if user_info.sub != token_context.subject:
+            raise ValueError(
+                "OAuth userinfo subject does not match the access token"
+            )
+
         # Determine security level based on roles or email domain
         security_level = await self._determine_security_level(user_info)
         
@@ -191,7 +230,7 @@ class OAuthAuthenticationProvider:
         session_id = f"oauth_{user_info.sub}_{datetime.utcnow().timestamp()}"
         
         return AuthContext(
-            token_id=f"oauth_{user_info.sub}",
+            token_id=token_context.token_id or f"oauth_{user_info.sub}",
             user_id=user_info.sub,
             roles=user_info.roles,
             permissions=permissions,
@@ -199,7 +238,15 @@ class OAuthAuthenticationProvider:
             session_id=session_id,
             login_time=datetime.utcnow(),
             last_activity=datetime.utcnow(),
-            token=""  # OAuth doesn't have raw token, use empty string
+            token="",  # OAuth doesn't have raw token, use empty string
+            auth_method="external_oauth",
+            oauth_client_id=token_context.client_id,
+            oauth_scopes=list(token_context.scopes),
+            oauth_token_id=token_context.token_id,
+            oauth_issuer=token_context.issuer,
+            oauth_resource=token_context.resource,
+            oauth_audiences=list(token_context.audiences),
+            pool_key="global",
         )
     
     async def _determine_security_level(self, user_info: OAuthUserInfo) -> 
SecurityLevel:
@@ -286,4 +333,4 @@ class OAuthAuthenticationProvider:
             "redirect_uri": config.redirect_uri,
             "pkce_enabled": config.pkce_enabled,
             "nonce_enabled": config.nonce_enabled
-        }
\ No newline at end of file
+        }
diff --git a/doris_mcp_server/auth/oauth_token_validation.py 
b/doris_mcp_server/auth/oauth_token_validation.py
new file mode 100644
index 0000000..e83e1af
--- /dev/null
+++ b/doris_mcp_server/auth/oauth_token_validation.py
@@ -0,0 +1,180 @@
+# 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.
+"""Fail-closed validation for externally issued OAuth access tokens."""
+
+import math
+import time
+from collections.abc import Mapping, Sequence
+from dataclasses import dataclass
+from hmac import compare_digest
+from typing import Any
+
+
+class OAuthAccessTokenValidationError(ValueError):
+    """An external access token failed resource-server validation."""
+
+    def __init__(self, error: str, description: str):
+        super().__init__(description)
+        self.error = error
+        self.description = description
+
+
+@dataclass(frozen=True, slots=True)
+class OAuthAccessTokenContext:
+    """Validated, non-secret claims used to build an authentication context."""
+
+    issuer: str
+    resource: str
+    audiences: tuple[str, ...]
+    scopes: tuple[str, ...]
+    subject: str
+    client_id: str = ""
+    token_id: str = ""
+    expires_at: int = 0
+
+
+def _claim_values(value: Any) -> tuple[str, ...]:
+    if isinstance(value, str):
+        return tuple(part for part in value.split() if part)
+    if isinstance(value, Sequence) and not isinstance(
+        value,
+        str | bytes | bytearray,
+    ):
+        return tuple(str(part) for part in value if str(part))
+    return ()
+
+
+class ExternalOAuthTokenValidator:
+    """Validate RFC 7662 claims for this MCP protected resource."""
+
+    def __init__(
+        self,
+        *,
+        issuer: str,
+        resource: str,
+        audience: str,
+        allowed_scopes: Sequence[str],
+        required_scopes: Sequence[str],
+    ):
+        self.issuer = issuer
+        self.resource = resource
+        self.audience = audience
+        self.allowed_scopes = frozenset(allowed_scopes)
+        self.required_scopes = frozenset(required_scopes)
+
+    def validate(
+        self,
+        claims: Mapping[str, Any],
+        *,
+        current_time: float | None = None,
+    ) -> OAuthAccessTokenContext:
+        """Validate active state and bind claims to this issuer and 
resource."""
+        if claims.get("active") is not True:
+            raise OAuthAccessTokenValidationError(
+                "invalid_token",
+                "OAuth access token is inactive",
+            )
+
+        token_issuer = str(claims.get("iss") or "")
+        if not token_issuer or not compare_digest(token_issuer, self.issuer):
+            raise OAuthAccessTokenValidationError(
+                "invalid_token",
+                "OAuth access token issuer mismatch",
+            )
+
+        audiences = _claim_values(claims.get("aud"))
+        if not any(compare_digest(value, self.audience) for value in 
audiences):
+            raise OAuthAccessTokenValidationError(
+                "invalid_token",
+                "OAuth access token audience mismatch",
+            )
+
+        token_resources = _claim_values(claims.get("resource"))
+        if token_resources and not any(
+            compare_digest(value, self.resource) for value in token_resources
+        ):
+            raise OAuthAccessTokenValidationError(
+                "invalid_token",
+                "OAuth access token resource mismatch",
+            )
+
+        now = time.time() if current_time is None else current_time
+        expires_at = claims.get("exp")
+        if (
+            isinstance(expires_at, bool)
+            or not isinstance(expires_at, int | float)
+            or not math.isfinite(expires_at)
+        ):
+            raise OAuthAccessTokenValidationError(
+                "invalid_token",
+                "OAuth access token expiration is missing",
+            )
+        if float(expires_at) <= now:
+            raise OAuthAccessTokenValidationError(
+                "invalid_token",
+                "OAuth access token has expired",
+            )
+
+        not_before = claims.get("nbf")
+        if not_before is not None:
+            if (
+                isinstance(not_before, bool)
+                or not isinstance(not_before, int | float)
+                or not math.isfinite(not_before)
+            ):
+                raise OAuthAccessTokenValidationError(
+                    "invalid_token",
+                    "OAuth access token not-before claim is invalid",
+                )
+            if float(not_before) > now:
+                raise OAuthAccessTokenValidationError(
+                    "invalid_token",
+                    "OAuth access token is not yet valid",
+                )
+
+        token_scopes = frozenset(
+            _claim_values(claims.get("scope") or claims.get("scp"))
+        )
+        if not self.required_scopes <= token_scopes:
+            raise OAuthAccessTokenValidationError(
+                "insufficient_scope",
+                "OAuth access token is missing a required scope",
+            )
+        granted_scopes = tuple(sorted(token_scopes & self.allowed_scopes))
+        if not granted_scopes:
+            raise OAuthAccessTokenValidationError(
+                "insufficient_scope",
+                "OAuth access token has no allowed scope",
+            )
+
+        subject = str(claims.get("sub") or "")
+        if not subject:
+            raise OAuthAccessTokenValidationError(
+                "invalid_token",
+                "OAuth access token subject is missing",
+            )
+
+        return OAuthAccessTokenContext(
+            issuer=token_issuer,
+            resource=self.resource,
+            audiences=audiences,
+            scopes=granted_scopes,
+            subject=subject,
+            client_id=str(claims.get("client_id") or ""),
+            token_id=str(claims.get("jti") or ""),
+            expires_at=int(expires_at),
+        )
diff --git a/doris_mcp_server/auth/oauth_types.py 
b/doris_mcp_server/auth/oauth_types.py
index ce9252f..6f69f7a 100644
--- a/doris_mcp_server/auth/oauth_types.py
+++ b/doris_mcp_server/auth/oauth_types.py
@@ -99,6 +99,7 @@ class OIDCDiscovery:
     issuer: str
     authorization_endpoint: str
     token_endpoint: str
+    introspection_endpoint: str | None = None
     userinfo_endpoint: Optional[str] = None
     jwks_uri: Optional[str] = None
     scopes_supported: List[str] = None
@@ -134,10 +135,17 @@ class OAuthProviderConfig:
     client_secret: str
     redirect_uri: str
     scopes: List[str]
+    required_scopes: list[str]
+    issuer: str
+    resource: str
+    audience: str
     
     # Endpoints
     authorization_endpoint: str
     token_endpoint: str
+    introspection_endpoint: str | None = None
+    introspection_client_id: str = ""
+    introspection_client_secret: str = ""
     userinfo_endpoint: Optional[str] = None
     jwks_uri: Optional[str] = None
     
@@ -193,4 +201,4 @@ OAUTH_PROVIDERS = {
         "email_claim": "email",
         "name_claim": "name"
     }
-}
\ No newline at end of file
+}
diff --git a/doris_mcp_server/utils/config.py b/doris_mcp_server/utils/config.py
index 806b2cf..07dba2d 100644
--- a/doris_mcp_server/utils/config.py
+++ b/doris_mcp_server/utils/config.py
@@ -145,6 +145,18 @@ def _coerce_csv_config(value: Any) -> list[str]:
     return [str(part).strip() for part in value if str(part).strip()]
 
 
+def _coerce_scope_config(value: Any) -> list[str]:
+    if value is None:
+        return []
+    if isinstance(value, str):
+        return [
+            part
+            for part in value.replace(",", " ").split()
+            if part
+        ]
+    return [str(part).strip() for part in value if str(part).strip()]
+
+
 def _validate_doris_oauth_metadata_tool_allowlist(tools: Any) -> list[str]:
     """Validate the Phase 4 metadata-only Doris OAuth tool allowlist."""
     normalized = []
@@ -175,6 +187,28 @@ def _is_loopback_url(url: str) -> bool:
     return (parsed.hostname or "").lower() in {"127.0.0.1", "localhost", "::1"}
 
 
+def _validate_external_oauth_url(
+    value: str,
+    *,
+    setting: str,
+    allow_query: bool = False,
+) -> str:
+    normalized = str(value or "").strip()
+    parsed = urlparse(normalized)
+    if not parsed.scheme or not parsed.netloc:
+        raise AuthConfigError(f"{setting} must be an absolute URL")
+    if parsed.fragment:
+        raise AuthConfigError(f"{setting} must not contain a fragment")
+    if parsed.query and not allow_query:
+        raise AuthConfigError(f"{setting} must not contain a query")
+    if parsed.scheme == "http":
+        if not _is_loopback_url(normalized):
+            raise AuthConfigError(f"{setting} must use HTTPS outside loopback")
+    elif parsed.scheme != "https":
+        raise AuthConfigError(f"{setting} must use HTTP or HTTPS")
+    return normalized
+
+
 def _is_loopback_bind_host(host: str) -> bool:
     """Return whether a bind host is explicitly confined to loopback."""
     normalized = str(host or "").strip().lower()
@@ -403,11 +437,18 @@ class SecurityConfig:
     oidc_discovery_url: str = ""  # e.g., 
https://accounts.google.com/.well-known/openid_configuration
     oauth_authorization_endpoint: str = ""
     oauth_token_endpoint: str = ""
+    oauth_introspection_endpoint: str = ""
+    oauth_introspection_client_id: str = ""
+    oauth_introspection_client_secret: str = ""
     oauth_userinfo_endpoint: str = ""
     oauth_jwks_uri: str = ""
+    oauth_issuer: str = ""
+    oauth_resource: str = ""
+    oauth_audience: str = ""
     
     # OAuth Scopes and Settings
     oauth_scopes: list[str] = field(default_factory=list)
+    oauth_required_scopes: list[str] = field(default_factory=list)
     oauth_state_expiry: int = 600  # State parameter expiry in seconds (10 
minutes)
     oauth_pkce_enabled: bool = True  # Enable PKCE for better security
     oauth_nonce_enabled: bool = True  # Enable nonce for OIDC
@@ -682,6 +723,44 @@ class DorisConfig:
         if "OAUTH_ENABLED" in os.environ:
             config.security.oauth_enabled = 
_str_to_bool(os.getenv("OAUTH_ENABLED"))
             _mark_source(config, "oauth_enabled", "env")
+        external_oauth_env = {
+            "OAUTH_PROVIDER_TYPE": "oauth_provider",
+            "OAUTH_CLIENT_ID": "oauth_client_id",
+            "OAUTH_CLIENT_SECRET": "oauth_client_secret",
+            "OAUTH_REDIRECT_URI": "oauth_redirect_uri",
+            "OAUTH_DISCOVERY_URL": "oidc_discovery_url",
+            "OAUTH_AUTHORIZATION_URL": "oauth_authorization_endpoint",
+            "OAUTH_TOKEN_URL": "oauth_token_endpoint",
+            "OAUTH_INTROSPECTION_URL": "oauth_introspection_endpoint",
+            "OAUTH_INTROSPECTION_CLIENT_ID": "oauth_introspection_client_id",
+            "OAUTH_INTROSPECTION_CLIENT_SECRET": 
"oauth_introspection_client_secret",
+            "OAUTH_USERINFO_URL": "oauth_userinfo_endpoint",
+            "OAUTH_JWKS_URL": "oauth_jwks_uri",
+            "OAUTH_ISSUER": "oauth_issuer",
+            "OAUTH_RESOURCE": "oauth_resource",
+            "OAUTH_AUDIENCE": "oauth_audience",
+            "OAUTH_USER_ID_CLAIM": "oauth_user_id_claim",
+            "OAUTH_EMAIL_CLAIM": "oauth_email_claim",
+            "OAUTH_ROLES_CLAIM": "oauth_roles_claim",
+        }
+        for env_name, field_name in external_oauth_env.items():
+            if env_name in os.environ:
+                setattr(
+                    config.security,
+                    field_name,
+                    os.getenv(env_name, "").strip(),
+                )
+                _mark_source(config, field_name, "env")
+        if "OAUTH_SCOPE" in os.environ:
+            config.security.oauth_scopes = _coerce_scope_config(
+                os.getenv("OAUTH_SCOPE")
+            )
+            _mark_source(config, "oauth_scopes", "env")
+        if "OAUTH_REQUIRED_SCOPE" in os.environ:
+            config.security.oauth_required_scopes = _coerce_scope_config(
+                os.getenv("OAUTH_REQUIRED_SCOPE")
+            )
+            _mark_source(config, "oauth_required_scopes", "env")
         if "ENABLE_DORIS_OAUTH_AUTH" in os.environ:
             config.security.enable_doris_oauth_auth = 
_str_to_bool(os.getenv("ENABLE_DORIS_OAUTH_AUTH"))
             _mark_source(config, "enable_doris_oauth_auth", "env")
@@ -1093,6 +1172,13 @@ class DorisConfig:
                 "enable_jwt_auth": self.security.enable_jwt_auth,
                 "enable_oauth_auth": self.security.enable_oauth_auth,
                 "oauth_enabled": self.security.oauth_enabled,
+                "oauth_provider": self.security.oauth_provider,
+                "oauth_issuer": self.security.oauth_issuer,
+                "oauth_resource": self.security.oauth_resource,
+                "oauth_audience": self.security.oauth_audience,
+                "oauth_scopes": self.security.oauth_scopes,
+                "oauth_required_scopes": self.security.oauth_required_scopes,
+                "oauth_introspection_endpoint": 
self.security.oauth_introspection_endpoint,
                 "enable_doris_oauth_auth": 
self.security.enable_doris_oauth_auth,
                 "allow_unauthenticated_non_loopback": 
self.security.allow_unauthenticated_non_loopback,
                 "doris_oauth_base_url": self.security.doris_oauth_base_url,
@@ -1509,6 +1595,95 @@ def normalize_effective_auth_config(
         except ValueError as exc:
             raise AuthConfigError(str(exc)) from exc
 
+    if enable_external_oauth_auth:
+        config.security.oauth_issuer = _validate_external_oauth_url(
+            config.security.oauth_issuer,
+            setting="OAUTH_ISSUER",
+        )
+        config.security.oauth_resource = _validate_external_oauth_url(
+            config.security.oauth_resource,
+            setting="OAUTH_RESOURCE",
+            allow_query=True,
+        )
+        config.security.oauth_audience = str(
+            config.security.oauth_audience
+            or config.security.oauth_resource
+        ).strip()
+        if not config.security.oauth_audience:
+            raise AuthConfigError("OAUTH_AUDIENCE is required")
+
+        config.security.oauth_scopes = list(
+            dict.fromkeys(
+                _coerce_scope_config(config.security.oauth_scopes)
+            )
+        )
+        if not config.security.oauth_scopes:
+            raise AuthConfigError(
+                "OAUTH_SCOPE must contain at least one allowed scope"
+            )
+        required_scopes = _coerce_scope_config(
+            config.security.oauth_required_scopes
+        )
+        if not required_scopes:
+            required_scopes = list(config.security.oauth_scopes)
+        if not set(required_scopes) <= set(config.security.oauth_scopes):
+            raise AuthConfigError(
+                "OAUTH_REQUIRED_SCOPE must be a subset of OAUTH_SCOPE"
+            )
+        config.security.oauth_required_scopes = list(
+            dict.fromkeys(required_scopes)
+        )
+
+        discovery_url = str(config.security.oidc_discovery_url or "").strip()
+        introspection_url = str(
+            config.security.oauth_introspection_endpoint or ""
+        ).strip()
+        if not discovery_url and not introspection_url:
+            raise AuthConfigError(
+                "OAUTH_INTROSPECTION_URL or OAUTH_DISCOVERY_URL is required"
+            )
+        if discovery_url:
+            config.security.oidc_discovery_url = (
+                _validate_external_oauth_url(
+                    discovery_url,
+                    setting="OAUTH_DISCOVERY_URL",
+                )
+            )
+        if introspection_url:
+            config.security.oauth_introspection_endpoint = (
+                _validate_external_oauth_url(
+                    introspection_url,
+                    setting="OAUTH_INTROSPECTION_URL",
+                )
+            )
+        if config.security.oauth_userinfo_endpoint:
+            config.security.oauth_userinfo_endpoint = (
+                _validate_external_oauth_url(
+                    config.security.oauth_userinfo_endpoint,
+                    setting="OAUTH_USERINFO_URL",
+                )
+            )
+        elif not discovery_url:
+            raise AuthConfigError(
+                "OAUTH_USERINFO_URL or OAUTH_DISCOVERY_URL is required"
+            )
+
+        config.security.oauth_introspection_client_id = str(
+            config.security.oauth_introspection_client_id
+            or config.security.oauth_client_id
+        ).strip()
+        config.security.oauth_introspection_client_secret = str(
+            config.security.oauth_introspection_client_secret
+            or config.security.oauth_client_secret
+        )
+        if (
+            not config.security.oauth_introspection_client_id
+            or not config.security.oauth_introspection_client_secret
+        ):
+            raise AuthConfigError(
+                "OAuth introspection client credentials are required"
+            )
+
     transport = str(inputs.transport.value or "stdio")
     requested = int(inputs.workers.value if inputs.workers.value is not None 
else 1)
     effective_workers = multiprocessing.cpu_count() if requested == 0 else 
requested
diff --git a/doris_mcp_server/utils/security.py 
b/doris_mcp_server/utils/security.py
index 9b490d0..3ffe093 100644
--- a/doris_mcp_server/utils/security.py
+++ b/doris_mcp_server/utils/security.py
@@ -76,6 +76,9 @@ class AuthContext:
     oauth_client_id: str = ""
     oauth_scopes: list[str] = field(default_factory=list)
     oauth_token_id: str = ""
+    oauth_issuer: str = ""
+    oauth_resource: str = ""
+    oauth_audiences: list[str] = field(default_factory=list)
     pool_key: str = ""
 
 
diff --git a/test/auth/test_external_oauth_config.py 
b/test/auth/test_external_oauth_config.py
new file mode 100644
index 0000000..1bf31da
--- /dev/null
+++ b/test/auth/test_external_oauth_config.py
@@ -0,0 +1,161 @@
+# 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.
+
+import pytest
+
+from doris_mcp_server.utils.config import (
+    AuthConfigError,
+    DorisConfig,
+    _mark_source,
+    normalize_effective_auth_config,
+)
+
+ISSUER = "https://issuer.example.test";
+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_client_id = "oauth-client"
+    config.security.oauth_client_secret = "oauth-secret"
+    config.security.oauth_issuer = ISSUER
+    config.security.oauth_resource = RESOURCE
+    config.security.oauth_audience = ""
+    config.security.oauth_scopes = [
+        "tool:list",
+        "resource:read",
+        "tool:list",
+    ]
+    config.security.oauth_required_scopes = ["resource:read"]
+    config.security.oauth_introspection_endpoint = f"{ISSUER}/introspect"
+    config.security.oauth_userinfo_endpoint = f"{ISSUER}/userinfo"
+    return config
+
+
+def test_external_oauth_context_configuration_is_normalized_fail_closed():
+    config = _external_oauth_config()
+
+    effective = normalize_effective_auth_config(config)
+
+    assert effective.auth_methods == ("external_oauth",)
+    assert effective.oauth_discovery_mode == "external_oauth"
+    assert config.security.oauth_issuer == ISSUER
+    assert config.security.oauth_resource == RESOURCE
+    assert config.security.oauth_audience == RESOURCE
+    assert config.security.oauth_scopes == ["tool:list", "resource:read"]
+    assert config.security.oauth_required_scopes == ["resource:read"]
+    assert config.security.oauth_introspection_client_id == "oauth-client"
+    assert config.security.oauth_introspection_client_secret == "oauth-secret"
+    serialized = config.to_dict()["security"]
+    assert "oauth_introspection_client_secret" not in serialized
+    assert "oauth_client_secret" not in serialized
+
+
[email protected](
+    ("field_name", "value", "message"),
+    [
+        ("oauth_issuer", "", "OAUTH_ISSUER"),
+        ("oauth_resource", "", "OAUTH_RESOURCE"),
+        ("oauth_scopes", [], "OAUTH_SCOPE"),
+        (
+            "oauth_introspection_endpoint",
+            "",
+            "OAUTH_INTROSPECTION_URL or OAUTH_DISCOVERY_URL",
+        ),
+        (
+            "oauth_userinfo_endpoint",
+            "",
+            "OAUTH_USERINFO_URL or OAUTH_DISCOVERY_URL",
+        ),
+        ("oauth_client_secret", "", "introspection client credentials"),
+    ],
+)
+def test_external_oauth_missing_trust_inputs_are_rejected(
+    field_name,
+    value,
+    message,
+):
+    config = _external_oauth_config()
+    setattr(config.security, field_name, value)
+
+    with pytest.raises(AuthConfigError, match=message):
+        normalize_effective_auth_config(config)
+
+
+def test_external_oauth_required_scopes_must_be_allowed():
+    config = _external_oauth_config()
+    config.security.oauth_required_scopes = ["unconfigured:admin"]
+
+    with pytest.raises(AuthConfigError, match="must be a subset"):
+        normalize_effective_auth_config(config)
+
+
+def test_external_oauth_rejects_insecure_remote_introspection():
+    config = _external_oauth_config()
+    config.security.oauth_introspection_endpoint = (
+        "http://issuer.example.test/introspect";
+    )
+
+    with pytest.raises(AuthConfigError, match="must use HTTPS"):
+        normalize_effective_auth_config(config)
+
+
+def test_external_oauth_environment_maps_token_context_settings(monkeypatch):
+    values = {
+        "ENABLE_OAUTH_AUTH": "true",
+        "ENABLE_DORIS_OAUTH_AUTH": "false",
+        "OAUTH_PROVIDER_TYPE": "custom",
+        "OAUTH_CLIENT_ID": "oauth-client",
+        "OAUTH_CLIENT_SECRET": "oauth-secret",
+        "OAUTH_REDIRECT_URI": "http://localhost:3000/auth/callback";,
+        "OAUTH_ISSUER": ISSUER,
+        "OAUTH_RESOURCE": RESOURCE,
+        "OAUTH_AUDIENCE": "mcp-api",
+        "OAUTH_AUTHORIZATION_URL": f"{ISSUER}/authorize",
+        "OAUTH_TOKEN_URL": f"{ISSUER}/token",
+        "OAUTH_INTROSPECTION_URL": f"{ISSUER}/introspect",
+        "OAUTH_INTROSPECTION_CLIENT_ID": "introspection-client",
+        "OAUTH_INTROSPECTION_CLIENT_SECRET": "introspection-secret",
+        "OAUTH_USERINFO_URL": f"{ISSUER}/userinfo",
+        "OAUTH_SCOPE": "tool:list, resource:list resource:read",
+        "OAUTH_REQUIRED_SCOPE": "tool:list resource:read",
+    }
+    for name, value in values.items():
+        monkeypatch.setenv(name, value)
+    for name in ("AUTH_TYPE", "OAUTH_ENABLED"):
+        monkeypatch.delenv(name, raising=False)
+
+    config = DorisConfig.from_env()
+    normalize_effective_auth_config(config)
+
+    assert config.security.oauth_provider == "custom"
+    assert config.security.oauth_issuer == ISSUER
+    assert config.security.oauth_resource == RESOURCE
+    assert config.security.oauth_audience == "mcp-api"
+    assert config.security.oauth_scopes == [
+        "tool:list",
+        "resource:list",
+        "resource:read",
+    ]
+    assert config.security.oauth_required_scopes == [
+        "tool:list",
+        "resource:read",
+    ]
+    assert config.security.oauth_introspection_client_id == 
("introspection-client")
+    assert config.security.oauth_introspection_client_secret == 
("introspection-secret")
diff --git a/test/auth/test_external_oauth_validation.py 
b/test/auth/test_external_oauth_validation.py
new file mode 100644
index 0000000..2b97a15
--- /dev/null
+++ b/test/auth/test_external_oauth_validation.py
@@ -0,0 +1,152 @@
+# 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.
+
+import pytest
+
+from doris_mcp_server.auth.oauth_provider import OAuthAuthenticationProvider
+from doris_mcp_server.auth.oauth_token_validation import (
+    OAuthAccessTokenContext,
+    OAuthAccessTokenValidationError,
+)
+from doris_mcp_server.auth.oauth_types import OAuthState, OAuthTokens, 
OAuthUserInfo
+
+ISSUER = "https://issuer.example.test";
+RESOURCE = "https://mcp.example.test/mcp";
+
+
+def _token_context(subject="user-1") -> OAuthAccessTokenContext:
+    return OAuthAccessTokenContext(
+        issuer=ISSUER,
+        resource=RESOURCE,
+        audiences=(RESOURCE,),
+        scopes=("resource:read", "tool:list"),
+        subject=subject,
+        client_id="caller-client",
+        token_id="token-1",
+        expires_at=200,
+    )
+
+
+class _FlowOAuthClient:
+    def __init__(self, *, token_subject="user-1", user_subject="user-1"):
+        self.events = []
+        self.token_subject = token_subject
+        self.user_subject = user_subject
+
+    async def exchange_code_for_tokens(self, code, state):
+        self.events.append(("exchange", code, state))
+        return (
+            OAuthTokens(
+                access_token="callback-access",
+                refresh_token="callback-refresh",
+            ),
+            OAuthState(state=state),
+        )
+
+    async def refresh_tokens(self, refresh_token):
+        self.events.append(("refresh", refresh_token))
+        return OAuthTokens(access_token="refreshed-access")
+
+    async def introspect_access_token(self, access_token):
+        self.events.append(("introspect", access_token))
+        return _token_context(self.token_subject)
+
+    async def get_user_info(self, tokens):
+        self.events.append(("userinfo", tokens.access_token))
+        return OAuthUserInfo(
+            sub=self.user_subject,
+            email="[email protected]",
+            roles=["oauth_user"],
+        )
+
+
+def _provider(client) -> OAuthAuthenticationProvider:
+    provider = object.__new__(OAuthAuthenticationProvider)
+    provider.enabled = True
+    provider.oauth_client = client
+    return provider
+
+
[email protected]
[email protected]("flow", ["callback", "bearer", "refresh"])
+async def test_every_external_oauth_entry_validates_before_userinfo(flow):
+    client = _FlowOAuthClient()
+    provider = _provider(client)
+
+    if flow == "callback":
+        context = await provider.handle_callback("code-1", "state-1")
+        assert client.events == [
+            ("exchange", "code-1", "state-1"),
+            ("introspect", "callback-access"),
+            ("userinfo", "callback-access"),
+        ]
+    elif flow == "bearer":
+        context = await provider.authenticate_with_token("bearer-access")
+        assert client.events == [
+            ("introspect", "bearer-access"),
+            ("userinfo", "bearer-access"),
+        ]
+    else:
+        context, access_token = await 
provider.refresh_authentication("refresh-1")
+        assert access_token == "refreshed-access"
+        assert client.events == [
+            ("refresh", "refresh-1"),
+            ("introspect", "refreshed-access"),
+            ("userinfo", "refreshed-access"),
+        ]
+
+    assert context.user_id == "user-1"
+    assert context.token_id == "token-1"
+    assert context.token == ""
+    assert context.auth_method == "external_oauth"
+    assert context.oauth_client_id == "caller-client"
+    assert context.oauth_scopes == ["resource:read", "tool:list"]
+    assert context.oauth_issuer == ISSUER
+    assert context.oauth_resource == RESOURCE
+    assert context.oauth_audiences == [RESOURCE]
+    assert context.pool_key == "global"
+
+
[email protected]
+async def test_failed_introspection_never_calls_userinfo():
+    class RejectingOAuthClient(_FlowOAuthClient):
+        async def introspect_access_token(self, access_token):
+            self.events.append(("introspect", access_token))
+            raise OAuthAccessTokenValidationError(
+                "invalid_token",
+                "inactive",
+            )
+
+    client = RejectingOAuthClient()
+    provider = _provider(client)
+
+    with pytest.raises(ValueError, match="inactive"):
+        await provider.authenticate_with_token("rejected-access")
+
+    assert client.events == [("introspect", "rejected-access")]
+
+
[email protected]
+async def test_userinfo_subject_must_match_introspected_subject():
+    client = _FlowOAuthClient(
+        token_subject="token-user",
+        user_subject="userinfo-user",
+    )
+    provider = _provider(client)
+
+    with pytest.raises(ValueError, match="userinfo subject does not match"):
+        await provider.authenticate_with_token("access-1")
diff --git a/test/auth/test_oauth_client_context.py 
b/test/auth/test_oauth_client_context.py
new file mode 100644
index 0000000..c8157e5
--- /dev/null
+++ b/test/auth/test_oauth_client_context.py
@@ -0,0 +1,193 @@
+# 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.
+
+import time
+from urllib.parse import parse_qs, urlparse
+
+import pytest
+
+from doris_mcp_server.auth.oauth_client import OAuthClient
+from doris_mcp_server.utils.config import (
+    DorisConfig,
+    _mark_source,
+    normalize_effective_auth_config,
+)
+
+ISSUER = "https://issuer.example.test";
+RESOURCE = "https://mcp.example.test/mcp";
+
+
+class _FakeResponse:
+    def __init__(self, status, payload):
+        self.status = status
+        self.payload = payload
+
+    async def __aenter__(self):
+        return self
+
+    async def __aexit__(self, exc_type, exc, traceback):
+        return False
+
+    async def json(self):
+        return self.payload
+
+    def raise_for_status(self):
+        if self.status >= 400:
+            raise RuntimeError(f"HTTP {self.status}")
+
+
+class _FakeSession:
+    def __init__(self, *, post_responses=None, get_responses=None):
+        self.post_responses = list(post_responses or [])
+        self.get_responses = list(get_responses or [])
+        self.post_calls = []
+        self.get_calls = []
+
+    def post(self, url, **kwargs):
+        self.post_calls.append((url, kwargs))
+        status, payload = self.post_responses.pop(0)
+        return _FakeResponse(status, payload)
+
+    def get(self, url, **kwargs):
+        self.get_calls.append((url, kwargs))
+        status, payload = self.get_responses.pop(0)
+        return _FakeResponse(status, payload)
+
+
+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", "resource:read"]
+    normalize_effective_auth_config(config)
+    return config
+
+
+def test_authorization_request_includes_resource_indicator():
+    client = OAuthClient(_external_oauth_config())
+
+    authorization_url, _ = client.build_authorization_url()
+    query = parse_qs(urlparse(authorization_url).query)
+
+    assert query["resource"] == [RESOURCE]
+    assert query["scope"] == ["tool:list resource:list resource:read"]
+
+
[email protected]
+async def test_token_exchange_and_refresh_preserve_resource_binding():
+    session = _FakeSession(
+        post_responses=[
+            (200, {"access_token": "access-1", "refresh_token": "refresh-1"}),
+            (200, {"access_token": "access-2"}),
+        ]
+    )
+    client = OAuthClient(_external_oauth_config())
+    client._session = session
+    _, oauth_state = client.build_authorization_url()
+
+    tokens, _ = await client.exchange_code_for_tokens(
+        "code-1",
+        oauth_state.state,
+    )
+    refreshed = await client.refresh_tokens(tokens.refresh_token)
+
+    assert tokens.access_token == "access-1"
+    assert refreshed.access_token == "access-2"
+    assert session.post_calls[0][1]["data"]["resource"] == RESOURCE
+    assert session.post_calls[1][1]["data"]["resource"] == RESOURCE
+
+
[email protected]
+async def test_introspection_uses_confidential_client_and_returns_context():
+    session = _FakeSession(
+        post_responses=[
+            (
+                200,
+                {
+                    "active": True,
+                    "iss": ISSUER,
+                    "aud": RESOURCE,
+                    "scope": (
+                        "tool:list resource:list resource:read 
unconfigured:admin"
+                    ),
+                    "sub": "user-1",
+                    "client_id": "caller-client",
+                    "jti": "token-1",
+                    "exp": int(time.time()) + 3600,
+                },
+            )
+        ]
+    )
+    client = OAuthClient(_external_oauth_config())
+    client._session = session
+
+    context = await client.introspect_access_token("access-secret")
+
+    endpoint, kwargs = session.post_calls[0]
+    assert endpoint == f"{ISSUER}/introspect"
+    assert kwargs["data"] == {
+        "token": "access-secret",
+        "token_type_hint": "access_token",
+    }
+    assert kwargs["auth"].login == "oauth-client"
+    assert kwargs["auth"].password == "oauth-secret"
+    assert context.subject == "user-1"
+    assert context.audiences == (RESOURCE,)
+    assert context.scopes == (
+        "resource:list",
+        "resource:read",
+        "tool:list",
+    )
+    assert "unconfigured:admin" not in context.scopes
+
+
[email protected]
+async def test_discovery_document_must_match_configured_issuer():
+    session = _FakeSession(
+        get_responses=[
+            (
+                200,
+                {
+                    "issuer": "https://other-issuer.example.test";,
+                    "authorization_endpoint": f"{ISSUER}/authorize",
+                    "token_endpoint": f"{ISSUER}/token",
+                },
+            )
+        ]
+    )
+    client = OAuthClient(_external_oauth_config())
+    client.provider_config.discovery_url = f"{ISSUER}/.well-known/oauth"
+    client._session = session
+
+    with pytest.raises(ValueError, match="issuer does not match"):
+        await client._discover_oidc_endpoints()
diff --git a/test/auth/test_oauth_token_validation.py 
b/test/auth/test_oauth_token_validation.py
new file mode 100644
index 0000000..c3edcd8
--- /dev/null
+++ b/test/auth/test_oauth_token_validation.py
@@ -0,0 +1,148 @@
+# 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.
+
+import pytest
+
+from doris_mcp_server.auth.oauth_token_validation import (
+    ExternalOAuthTokenValidator,
+    OAuthAccessTokenValidationError,
+)
+
+ISSUER_A = "https://issuer-a.example.test";
+ISSUER_B = "https://issuer-b.example.test";
+RESOURCE_A = "https://mcp-a.example.test/mcp";
+RESOURCE_B = "https://mcp-b.example.test/mcp";
+ALLOWED_SCOPES = ("tool:list", "resource:list", "resource:read")
+REQUIRED_SCOPES = ("tool:list", "resource:read")
+
+
+def _validator() -> ExternalOAuthTokenValidator:
+    return ExternalOAuthTokenValidator(
+        issuer=ISSUER_A,
+        resource=RESOURCE_A,
+        audience=RESOURCE_A,
+        allowed_scopes=ALLOWED_SCOPES,
+        required_scopes=REQUIRED_SCOPES,
+    )
+
+
+def _claims(
+    *,
+    issuer: str = ISSUER_A,
+    resource: str = RESOURCE_A,
+    scopes: tuple[str, ...] = REQUIRED_SCOPES,
+) -> dict[str, object]:
+    return {
+        "active": True,
+        "iss": issuer,
+        "aud": [resource],
+        "resource": resource,
+        "scope": " ".join(scopes),
+        "sub": "user-1",
+        "client_id": "client-1",
+        "jti": "token-1",
+        "exp": 200,
+    }
+
+
[email protected]("issuer", [ISSUER_A, ISSUER_B])
[email protected]("resource", [RESOURCE_A, RESOURCE_B])
[email protected](
+    ("scope_case", "scopes", "scope_is_valid", "expected_scopes"),
+    [
+        ("insufficient", ("tool:list",), False, ()),
+        ("minimum", REQUIRED_SCOPES, True, tuple(sorted(REQUIRED_SCOPES))),
+        (
+            "superset",
+            (*ALLOWED_SCOPES, "unconfigured:admin"),
+            True,
+            tuple(sorted(ALLOWED_SCOPES)),
+        ),
+    ],
+)
+def test_two_issuer_two_resource_three_scope_matrix_is_least_privilege(
+    issuer,
+    resource,
+    scope_case,
+    scopes,
+    scope_is_valid,
+    expected_scopes,
+):
+    claims = _claims(issuer=issuer, resource=resource, scopes=scopes)
+    should_accept = issuer == ISSUER_A and resource == RESOURCE_A and 
scope_is_valid
+
+    if not should_accept:
+        with pytest.raises(OAuthAccessTokenValidationError) as exc_info:
+            _validator().validate(claims, current_time=100)
+        if issuer != ISSUER_A or resource != RESOURCE_A:
+            assert exc_info.value.error == "invalid_token"
+        else:
+            assert scope_case == "insufficient"
+            assert exc_info.value.error == "insufficient_scope"
+        return
+
+    context = _validator().validate(claims, current_time=100)
+
+    assert context.issuer == ISSUER_A
+    assert context.resource == RESOURCE_A
+    assert context.audiences == (RESOURCE_A,)
+    assert context.scopes == expected_scopes
+    assert "unconfigured:admin" not in context.scopes
+    assert context.subject == "user-1"
+    assert context.client_id == "client-1"
+    assert context.token_id == "token-1"
+    assert context.expires_at == 200
+
+
[email protected](
+    ("claim", "value", "error"),
+    [
+        ("active", False, "invalid_token"),
+        ("exp", 100, "invalid_token"),
+        ("exp", float("nan"), "invalid_token"),
+        ("nbf", 101, "invalid_token"),
+        ("sub", "", "invalid_token"),
+    ],
+)
+def test_invalid_lifecycle_or_identity_claims_fail_closed(claim, value, error):
+    claims = _claims()
+    claims[claim] = value
+
+    with pytest.raises(OAuthAccessTokenValidationError) as exc_info:
+        _validator().validate(claims, current_time=100)
+
+    assert exc_info.value.error == error
+
+
+def test_resource_claim_cannot_override_a_valid_audience():
+    claims = _claims()
+    claims["resource"] = RESOURCE_B
+
+    with pytest.raises(
+        OAuthAccessTokenValidationError,
+        match="resource mismatch",
+    ):
+        _validator().validate(claims, current_time=100)
+
+
+def test_rfc7662_response_without_nonstandard_resource_uses_audience_binding():
+    claims = _claims()
+    del claims["resource"]
+
+    context = _validator().validate(claims, current_time=100)
+
+    assert context.resource == RESOURCE_A


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

Reply via email to