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]