This is an automated email from the ASF dual-hosted git repository. FreeOnePlus pushed a commit to branch agent/sec-007-oauth-context-validation in repository https://gitbox.apache.org/repos/asf/doris-mcp-server.git
commit de4728f501da4a9f4ad75c7cb7fa067f24e2c42d Author: FreeOnePlus <[email protected]> AuthorDate: Wed Jul 29 20:53:00 2026 +0800 fix: enforce external OAuth token context --- .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]
