From baa7d91d9dc164e8d9a6ac6b04b508de4e131ff4 Mon Sep 17 00:00:00 2001 From: Rodion Mostovoi <36400912+rodion-m@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:35:20 +0500 Subject: [PATCH 1/5] feat: add OAuth 2.1 support --- README.md | 31 +++ src/codealive_mcp_server.py | 26 +- src/core/__init__.py | 14 + src/core/client.py | 9 +- src/core/config.py | 85 +++++++ src/core/oauth.py | 295 ++++++++++++++++++++++ src/tests/test_http_transport_security.py | 26 ++ src/tests/test_oauth.py | 223 ++++++++++++++++ src/tests/test_protocol_tools.py | 2 +- src/tests/test_stdio_smoke.py | 4 +- src/tests/test_tool_api_v3.py | 65 ++++- src/tools/tool_api.py | 39 ++- 12 files changed, 800 insertions(+), 19 deletions(-) create mode 100644 src/core/oauth.py create mode 100644 src/tests/test_oauth.py diff --git a/README.md b/README.md index 459f0d9..38e9c80 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,10 @@ The equivalent repeatable CLI options are `--allowed-host` and After making changes, quickly verify everything works: ```bash +# Match pyproject.toml exactly; older uv versions reject the locked setup. +uv --version # expected: uv 0.11.28 +uv sync --locked --extra test + # Install the repository pre-push dependency audit once per clone ./scripts/setup-hooks.sh @@ -223,6 +227,9 @@ make unit-test # Run all tests make test + +# Equivalent direct locked test run +uv run pytest src/tests/ -q ``` The smoke test verifies: @@ -281,6 +288,30 @@ curl http://localhost:8000/health See `docker-compose.example.yml` for the complete configuration template. +### OAuth 2.1 deployment profile + +Remote HTTP deployments can enable browser authorization while keeping legacy API-key clients +working during rollout. OAuth mode publishes MCP Protected Resource Metadata, validates exact +issuer/resource-bound JWTs, and exchanges them for a separate short-lived Tool API token. The +incoming MCP bearer token is never forwarded downstream. + +| Environment variable | Purpose | +|---|---| +| `CODEALIVE_MCP_OAUTH_ENABLED=true` | Enables OAuth validation and MCP authorization discovery for HTTP transport | +| `CODEALIVE_OAUTH_ISSUER` | Exact OpenIddict issuer, with a trailing slash | +| `CODEALIVE_MCP_RESOURCE` | Exact public MCP resource URL; its path is also the HTTP MCP path | +| `CODEALIVE_TOOL_API_RESOURCE` | Downstream audience; defaults to `urn:codealive:tool-api` | +| `CODEALIVE_OAUTH_INTERNAL_CLIENT_ID` | Confidential resource-server client used only for token exchange | +| `CODEALIVE_OAUTH_INTERNAL_CLIENT_SECRET` | Required secret for that internal client; startup fails closed when it is missing | + +The authorization server and MCP service values must match exactly. In CodeAlive Web.Server the +corresponding settings live under `McpOAuth` (`Enabled`, `Issuer`, `Resource`, +`ToolApiResource`, `InternalClientId`, and `InternalClientSecret`). Persist the Web.Server Data +Protection key ring and OpenIddict signing/encryption certificates across replicas and restarts. +Enable the Web.Server and MCP flags in the same rollout; a half-enabled deployment is not a valid +steady state. API-key credentials retain their explicit legacy grammar and are never used as a +fallback after OAuth validation fails. + ### Connecting MCP Clients to Your Deployed Instance Use the same generic connection details as CodeAlive Cloud, replacing the endpoint with your deployment's `/api` URL: diff --git a/src/codealive_mcp_server.py b/src/codealive_mcp_server.py index 4f75a1b..5ec74b1 100644 --- a/src/codealive_mcp_server.py +++ b/src/codealive_mcp_server.py @@ -10,6 +10,7 @@ import sys from importlib.metadata import PackageNotFoundError, version from pathlib import Path +from urllib.parse import urlsplit from dotenv import load_dotenv from fastmcp import FastMCP @@ -26,7 +27,7 @@ sys.path.insert(0, str(Path(__file__).parent)) # Import core components -from core import codealive_lifespan, setup_logging, setup_debug_logging, init_tracing, normalize_base_url, _server_ready +from core import Config, build_oauth_provider, codealive_lifespan, setup_logging, setup_debug_logging, init_tracing, normalize_base_url, _server_ready import core.client as _client_module # for /ready flag access from middleware import N8NRemoveParametersMiddleware, ObservabilityMiddleware from tools import ( @@ -206,13 +207,11 @@ def main(): os.environ["CODEALIVE_BASE_URL"] = normalized_base_url logger.info("Using base URL from command line: {url}", url=normalized_base_url) - # Disable SSL verification if explicitly requested or in debug mode - if args.ignore_ssl or debug: + # Debug logging must not weaken transport security. TLS verification is disabled only + # through the explicit opt-in flag used for local self-signed development endpoints. + if args.ignore_ssl: os.environ["CODEALIVE_IGNORE_SSL"] = "true" - if args.ignore_ssl: - logger.warning("SSL certificate validation disabled by --ignore-ssl flag") - elif debug: - logger.warning("SSL certificate validation disabled in debug mode") + logger.warning("SSL certificate validation disabled by --ignore-ssl flag") if debug: logger.debug( @@ -247,6 +246,15 @@ def main(): ) logger.info("HTTP mode: API keys extracted from Authorization: Bearer headers") + oauth_config = Config.from_environment() + if oauth_config.oauth_enabled: + if not oauth_config.oauth_internal_client_secret: + logger.error( + "OAuth mode requires CODEALIVE_OAUTH_INTERNAL_CLIENT_SECRET for downstream token exchange" + ) + sys.exit(1) + mcp.auth = build_oauth_provider(oauth_config) + if not base_url: logger.info( "CODEALIVE_BASE_URL not set, using default: https://app.codealive.ai" @@ -254,6 +262,8 @@ def main(): # Run the server with the selected transport if args.transport == "http": + oauth_config = Config.from_environment() + mcp_path = urlsplit(oauth_config.mcp_resource).path or "/api" allowed_hosts = args.allowed_host or [ value.strip() for value in os.getenv("CODEALIVE_MCP_ALLOWED_HOSTS", "").split(",") @@ -269,7 +279,7 @@ def main(): transport="http", host=args.host, port=args.port, - path="/api", + path=mcp_path, stateless_http=True, host_origin_protection=True, allowed_hosts=allowed_hosts or None, diff --git a/src/core/__init__.py b/src/core/__init__.py index 6b6f345..ea5bd41 100644 --- a/src/core/__init__.py +++ b/src/core/__init__.py @@ -1,6 +1,14 @@ """Core components for CodeAlive MCP server.""" from .client import CodeAliveContext, get_api_key_from_context, codealive_lifespan, _server_ready +from .oauth import ( + ToolTokenExchangeCache, + build_oauth_provider, + exchange_for_tool_token, + invalidate_tool_token_exchange, + is_oauth_credential, + is_jwt_shaped, +) from .config import Config, REQUEST_TIMEOUT_SECONDS, normalize_base_url from .logging import setup_logging, setup_debug_logging, log_api_request, log_api_response from .observability import init_tracing @@ -8,6 +16,12 @@ __all__ = [ 'CodeAliveContext', 'get_api_key_from_context', + 'build_oauth_provider', + 'exchange_for_tool_token', + 'invalidate_tool_token_exchange', + 'is_oauth_credential', + 'ToolTokenExchangeCache', + 'is_jwt_shaped', 'codealive_lifespan', 'Config', 'REQUEST_TIMEOUT_SECONDS', diff --git a/src/core/client.py b/src/core/client.py index 41875bd..2187e04 100644 --- a/src/core/client.py +++ b/src/core/client.py @@ -11,6 +11,7 @@ from loguru import logger from .config import Config, REQUEST_TIMEOUT_SECONDS +from .oauth import ToolTokenExchangeCache @dataclass @@ -19,6 +20,8 @@ class CodeAliveContext: client: httpx.AsyncClient api_key: str base_url: str + config: Config | None = None + tool_token_cache: ToolTokenExchangeCache | None = None # Module-level readiness state for the /ready endpoint. @@ -96,8 +99,10 @@ async def codealive_lifespan(server: FastMCP) -> AsyncIterator[CodeAliveContext] yield CodeAliveContext( client=client, api_key="", # Will be set per-request in HTTP mode - base_url=config.base_url + base_url=config.base_url, + config=config, + tool_token_cache=ToolTokenExchangeCache(), ) finally: _server_ready = False - await client.aclose() \ No newline at end of file + await client.aclose() diff --git a/src/core/config.py b/src/core/config.py index 96b23df..42908e3 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -1,6 +1,7 @@ """Configuration management for CodeAlive MCP server.""" import os +import ipaddress from dataclasses import dataclass from typing import Optional from urllib.parse import urlsplit, urlunsplit @@ -9,6 +10,70 @@ REQUEST_TIMEOUT_SECONDS = 300.0 +def _is_loopback_host(host: str | None) -> bool: + if host is None: + return False + if host.lower() == "localhost": + return True + try: + return ipaddress.ip_address(host).is_loopback + except ValueError: + return False + + +def validate_oauth_urls(issuer_value: str, resource_value: str) -> None: + issuer = urlsplit(issuer_value) + if ( + issuer.scheme != "https" + or not issuer.netloc + or issuer.username is not None + or issuer.password is not None + or issuer.path not in {"", "/"} + or issuer.query + or issuer.fragment + or not issuer_value.endswith("/") + or (issuer.hostname or "").endswith(".") + ): + raise ValueError("CODEALIVE_OAUTH_ISSUER must be a canonical HTTPS origin") + + resource = urlsplit(resource_value) + secure = resource.scheme == "https" or ( + resource.scheme == "http" and _is_loopback_host(resource.hostname) + ) + if ( + not secure + or not resource.netloc + or resource.username is not None + or resource.password is not None + or resource.path in {"", "/"} + or resource.query + or resource.fragment + or resource_value.endswith("/") + or (resource.hostname or "").endswith(".") + ): + raise ValueError("CODEALIVE_MCP_RESOURCE must be a canonical HTTPS URL with a path") + + +def _same_resource_identifier(left_value: str, right_value: str) -> bool: + left = urlsplit(left_value) + right = urlsplit(right_value) + if left.scheme.lower() != right.scheme.lower(): + return False + if left.netloc or right.netloc: + left_port = left.port or (443 if left.scheme.lower() == "https" else 80 if left.scheme.lower() == "http" else None) + right_port = right.port or (443 if right.scheme.lower() == "https" else 80 if right.scheme.lower() == "http" else None) + return ( + left.hostname == right.hostname + and left_port == right_port + and left.username == right.username + and left.password == right.password + and left.path == right.path + and left.query == right.query + and left.fragment == right.fragment + ) + return left.path == right.path and left.query == right.query and left.fragment == right.fragment + + def normalize_base_url(base_url: Optional[str]) -> str: """Normalize a CodeAlive base URL to the deployment origin. @@ -41,6 +106,20 @@ class Config: transport_mode: str = "stdio" verify_ssl: bool = True debug_mode: bool = False + oauth_enabled: bool = False + oauth_issuer: str = "https://auth.codealive.ai/" + mcp_resource: str = "https://mcp.codealive.ai/api" + tool_api_resource: str = "urn:codealive:tool-api" + oauth_internal_client_id: str = "codealive-mcp" + oauth_internal_client_secret: Optional[str] = None + + def __post_init__(self) -> None: + if self.oauth_enabled: + validate_oauth_urls(self.oauth_issuer, self.mcp_resource) + if _same_resource_identifier(self.mcp_resource, self.tool_api_resource): + raise ValueError( + "CODEALIVE_MCP_RESOURCE and CODEALIVE_TOOL_API_RESOURCE must be distinct" + ) @classmethod def from_environment(cls) -> "Config": @@ -51,4 +130,10 @@ def from_environment(cls) -> "Config": transport_mode=os.environ.get("TRANSPORT_MODE", "stdio"), verify_ssl=not os.environ.get("CODEALIVE_IGNORE_SSL", "").lower() in ["true", "1", "yes"], debug_mode=os.environ.get("DEBUG_MODE", "").lower() in ["true", "1", "yes"], + oauth_enabled=os.environ.get("CODEALIVE_MCP_OAUTH_ENABLED", "false").lower() in ["true", "1", "yes"], + oauth_issuer=os.environ.get("CODEALIVE_OAUTH_ISSUER", "https://auth.codealive.ai/"), + mcp_resource=os.environ.get("CODEALIVE_MCP_RESOURCE", "https://mcp.codealive.ai/api"), + tool_api_resource=os.environ.get("CODEALIVE_TOOL_API_RESOURCE", "urn:codealive:tool-api").rstrip("/"), + oauth_internal_client_id=os.environ.get("CODEALIVE_OAUTH_INTERNAL_CLIENT_ID", "codealive-mcp"), + oauth_internal_client_secret=os.environ.get("CODEALIVE_OAUTH_INTERNAL_CLIENT_SECRET"), ) diff --git a/src/core/oauth.py b/src/core/oauth.py new file mode 100644 index 0000000..490b161 --- /dev/null +++ b/src/core/oauth.py @@ -0,0 +1,295 @@ +"""OAuth resource-server validation and downstream token exchange.""" + +from __future__ import annotations + +import asyncio +import hashlib +import re +import time +from collections import OrderedDict +from collections.abc import Awaitable, Callable +from urllib.parse import urljoin, urlsplit, urlunsplit + +import httpx +from fastmcp.server.auth import AccessToken, RemoteAuthProvider, TokenVerifier +from fastmcp.server.auth.providers.jwt import JWTVerifier +from starlette.middleware import Middleware +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.routing import Route + +from .config import Config, validate_oauth_urls + +_OBJECT_ID = re.compile(r"^[0-9a-fA-F]{24}$") +_ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token" +_TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange" +_LEGACY_API_KEY = re.compile(r"^ca_[0-9]{10,16}_[A-Za-z0-9_-]{43}$") + + +def is_jwt_shaped(token: str) -> bool: + """Distinguish OAuth JWTs from opaque legacy API keys without decoding claims.""" + return token.count(".") == 2 + + +def is_legacy_api_key(token: str) -> bool: + """Recognize the one historic credential grammar; everything else fails as OAuth.""" + return _LEGACY_API_KEY.fullmatch(token) is not None + + +def is_oauth_credential(token: str) -> bool: + return not is_legacy_api_key(token) + + +class CodeAliveTokenVerifier(TokenVerifier): + """Accept legacy opaque API keys or strictly validate CodeAlive MCP JWTs.""" + + def __init__(self, config: Config): + super().__init__(required_scopes=["mcp:tools"]) + self._config = config + self._jwt = JWTVerifier( + jwks_uri=urljoin(config.oauth_issuer, "connect/jwks"), + issuer=config.oauth_issuer, + audience=config.mcp_resource, + algorithm="RS256", + required_scopes=None, + ) + + @property + def scopes_supported(self) -> list[str]: + return ["mcp:tools"] + + async def verify_token(self, token: str) -> AccessToken | None: + if not token or len(token) > 16_384: + return None + if is_legacy_api_key(token): + # Legacy API keys are opaque and authoritatively validated by the Tool API. + credential_id = hashlib.sha256(token.encode()).hexdigest() + return AccessToken( + token=token, + client_id="legacy-api-key", + scopes=["mcp:tools"], + subject=f"legacy:{credential_id}", + ) + + if not is_jwt_shaped(token): + return None + access = await self._jwt.verify_token(token) + if access is None: + return None + claims = access.claims or {} + audience = claims.get("aud") + exact_audience = audience == self._config.mcp_resource or audience == [self._config.mcp_resource] + if not exact_audience: + return None + if set(access.scopes or []) != {"mcp:tools"}: + return None + required_string_claims = ("sub", "organisation_id", "mcp_connection_id", "client_id") + if any(not isinstance(claims.get(name), str) or not claims[name] for name in required_string_claims): + return None + if access.client_id is not None and access.client_id != claims["client_id"]: + return None + if not _OBJECT_ID.fullmatch(claims["sub"]) or not _OBJECT_ID.fullmatch(claims["organisation_id"]) or not _OBJECT_ID.fullmatch(claims["mcp_connection_id"]): + return None + # The MCP SDK binds stateful transport sessions to subject/client/issuer. + # Include the connection in the subject so two grants for the same User/client + # cannot reuse one another's transport continuity id. + return AccessToken( + token=access.token, + client_id=access.client_id, + scopes=access.scopes, + expires_at=access.expires_at, + claims=claims, + subject=f"{claims['sub']}:{claims['mcp_connection_id']}", + ) + + +class OAuthChallengeMiddleware: + """Add the RFC 9728 challenge attributes required by MCP clients.""" + + def __init__(self, app, *, resource_path: str, metadata_url: str): + self.app = app + self.resource_path = resource_path + self.metadata_url = metadata_url + + async def __call__(self, scope, receive, send): + if scope.get("type") != "http" or scope.get("path") != self.resource_path: + return await self.app(scope, receive, send) + + async def send_with_challenge(message): + if message.get("type") == "http.response.start" and message.get("status") in {401, 403}: + status = message["status"] + error = ', error="insufficient_scope"' if status == 403 else "" + challenge = ( + f'Bearer realm="codealive-mcp", resource_metadata="{self.metadata_url}", ' + f'scope="mcp:tools"{error}' + ) + headers = [(key, value) for key, value in message.get("headers", []) if key.lower() != b"www-authenticate"] + headers.append((b"www-authenticate", challenge.encode("ascii"))) + message = {**message, "headers": headers} + await send(message) + + await self.app(scope, receive, send_with_challenge) + + +class CodeAliveRemoteAuthProvider(RemoteAuthProvider): + def __init__(self, config: Config, base_url: str, resource_path: str): + super().__init__( + token_verifier=CodeAliveTokenVerifier(config), + authorization_servers=[config.oauth_issuer], + base_url=base_url, + resource_base_url=base_url, + scopes_supported=["mcp:tools"], + resource_name="CodeAlive MCP", + ) + self._config = config + self._resource_path = resource_path + self._metadata_path = f"/.well-known/oauth-protected-resource{resource_path}" + self._metadata_url = f"{base_url.rstrip('/')}{self._metadata_path}" + + def get_routes(self, mcp_path: str | None = None) -> list[Route]: + self.set_mcp_path(mcp_path) + + async def protected_resource_metadata(_: Request) -> JSONResponse: + return JSONResponse( + { + "resource": self._config.mcp_resource, + "authorization_servers": [self._config.oauth_issuer], + "scopes_supported": ["mcp:tools"], + "bearer_methods_supported": ["header"], + }, + headers={"Cache-Control": "public, max-age=300"}, + ) + + return [Route(self._metadata_path, protected_resource_metadata, methods=["GET"])] + + def get_middleware(self) -> list: + return [ + *super().get_middleware(), + Middleware( + OAuthChallengeMiddleware, + resource_path=self._resource_path, + metadata_url=self._metadata_url, + ), + ] + + +def build_oauth_provider(config: Config) -> RemoteAuthProvider: + validate_oauth_urls(config.oauth_issuer, config.mcp_resource) + resource = urlsplit(config.mcp_resource) + base_url = urlunsplit((resource.scheme, resource.netloc, "", "", "")) + return CodeAliveRemoteAuthProvider(config, base_url, resource.path) + + +class ToolTokenExchangeCache: + """Small in-memory, per-process cache with concurrent exchange coalescing.""" + + def __init__(self, maximum_entries: int = 512): + self._maximum_entries = maximum_entries + self._entries: OrderedDict[str, tuple[str, float]] = OrderedDict() + self._inflight: dict[str, tuple[asyncio.Task[tuple[str, float]], int]] = {} + self._generation = 0 + self._lock = asyncio.Lock() + + async def get_or_create( + self, + key: str, + factory: Callable[[], Awaitable[tuple[str, float]]], + ) -> str: + now = time.monotonic() + async with self._lock: + cached = self._entries.get(key) + if cached is not None and cached[1] > now: + self._entries.move_to_end(key) + return cached[0] + if cached is not None: + self._entries.pop(key, None) + generation = self._generation + inflight = self._inflight.get(key) + if inflight is None: + task = asyncio.create_task(factory()) + self._inflight[key] = (task, generation) + else: + task, generation = inflight + + try: + token, cache_until = await asyncio.shield(task) + finally: + async with self._lock: + if self._inflight.get(key) == (task, generation): + self._inflight.pop(key, None) + + if cache_until > time.monotonic(): + async with self._lock: + if self._generation == generation: + self._entries[key] = (token, cache_until) + self._entries.move_to_end(key) + while len(self._entries) > self._maximum_entries: + self._entries.popitem(last=False) + return token + + async def invalidate(self, key: str) -> None: + """Remove a rejected token without exposing the subject token in cache state.""" + async with self._lock: + self._generation += 1 + self._entries.pop(key, None) + self._inflight.pop(key, None) + + +def _tool_token_exchange_cache_key(config: Config, subject_token: str) -> str: + return hashlib.sha256( + "\0".join(( + subject_token, + config.oauth_internal_client_id, + config.tool_api_resource, + "mcp:tools", + )).encode() + ).hexdigest() + + +async def invalidate_tool_token_exchange( + cache: ToolTokenExchangeCache | None, + config: Config, + subject_token: str, +) -> None: + """Evict the downstream token derived from one inbound MCP token.""" + if cache is not None: + await cache.invalidate(_tool_token_exchange_cache_key(config, subject_token)) + + +async def exchange_for_tool_token( + client: httpx.AsyncClient, + config: Config, + subject_token: str, + cache: ToolTokenExchangeCache | None = None, +) -> str: + """Exchange an inbound MCP token; the subject token is never sent to Tool API.""" + if not config.oauth_internal_client_secret: + raise ValueError("OAuth token exchange is not configured") + async def request_token() -> tuple[str, float]: + response = await client.post( + urljoin(config.oauth_issuer, "connect/token"), + data={ + "grant_type": _TOKEN_EXCHANGE_GRANT, + "subject_token": subject_token, + "subject_token_type": _ACCESS_TOKEN_TYPE, + "requested_token_type": _ACCESS_TOKEN_TYPE, + "resource": config.tool_api_resource, + "scope": "mcp:tools", + }, + auth=(config.oauth_internal_client_id, config.oauth_internal_client_secret), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + response.raise_for_status() + payload = response.json() + token = payload.get("access_token") + expires_in = payload.get("expires_in") + if not isinstance(token, str) or not token: + raise ValueError("OAuth token exchange returned no access token") + cache_seconds = max(0.0, float(expires_in) - 30.0) if isinstance(expires_in, (int, float)) else 0.0 + return token, time.monotonic() + cache_seconds + + if cache is None: + token, _ = await request_token() + return token + cache_key = _tool_token_exchange_cache_key(config, subject_token) + return await cache.get_or_create(cache_key, request_token) diff --git a/src/tests/test_http_transport_security.py b/src/tests/test_http_transport_security.py index 7e9d89b..60885cd 100644 --- a/src/tests/test_http_transport_security.py +++ b/src/tests/test_http_transport_security.py @@ -97,3 +97,29 @@ def test_http_main_enables_guard_and_reads_environment_allowlists(monkeypatch): assert options["host_origin_protection"] is True assert options["allowed_hosts"] == ["mcp.codealive.ai", "codealive-mcp-server"] assert options["allowed_origins"] == ["https://mcp.codealive.ai"] + + +def test_http_main_fails_closed_when_oauth_exchange_secret_is_missing(monkeypatch): + monkeypatch.setattr(server, "setup_logging", MagicMock()) + monkeypatch.setattr(server, "init_tracing", MagicMock()) + monkeypatch.setenv("CODEALIVE_MCP_OAUTH_ENABLED", "true") + monkeypatch.delenv("CODEALIVE_OAUTH_INTERNAL_CLIENT_SECRET", raising=False) + monkeypatch.setattr(sys, "argv", ["codealive-mcp", "--transport", "http"]) + + with pytest.raises(SystemExit) as error: + server.main() + + assert error.value.code == 1 + + +def test_debug_mode_does_not_disable_tls_verification(monkeypatch): + run = MagicMock() + monkeypatch.setattr(server.mcp, "run", run) + monkeypatch.setattr(server, "setup_logging", MagicMock()) + monkeypatch.setattr(server, "init_tracing", MagicMock()) + monkeypatch.delenv("CODEALIVE_IGNORE_SSL", raising=False) + monkeypatch.setattr(sys, "argv", ["codealive-mcp", "--transport", "http", "--debug"]) + + server.main() + + assert "CODEALIVE_IGNORE_SSL" not in server.os.environ diff --git a/src/tests/test_oauth.py b/src/tests/test_oauth.py new file mode 100644 index 0000000..895ffe5 --- /dev/null +++ b/src/tests/test_oauth.py @@ -0,0 +1,223 @@ +"""OAuth 2.1 resource-server and token-exchange contracts.""" + +import asyncio + +from unittest.mock import AsyncMock + +import httpx +import pytest +from fastmcp import FastMCP +from fastmcp.server.auth import AccessToken + +from core.config import Config +from core.oauth import ( + CodeAliveTokenVerifier, + ToolTokenExchangeCache, + build_oauth_provider, + exchange_for_tool_token, +) + + +def _config(**changes) -> Config: + values = { + "oauth_issuer": "https://auth.codealive.ai/", + "mcp_resource": "https://mcp.codealive.ai/api", + "tool_api_resource": "urn:codealive:tool-api", + "oauth_internal_client_id": "codealive-mcp", + "oauth_internal_client_secret": "test-secret", + } + values.update(changes) + return Config(**values) + + +@pytest.mark.parametrize("issuer", [ + "http://auth.codealive.ai", + "https://user@auth.codealive.ai", + "https://auth.codealive.ai/tenant", + "https://auth.codealive.ai", +]) +def test_oauth_provider_rejects_noncanonical_issuer(issuer): + with pytest.raises(ValueError, match="OAUTH_ISSUER"): + build_oauth_provider(_config(oauth_issuer=issuer)) + + +@pytest.mark.parametrize("resource", [ + "http://mcp.codealive.ai/api", + "https://user@mcp.codealive.ai/api", + "https://mcp.codealive.ai", + "https://mcp.codealive.ai/api?tenant=x", + "https://mcp.codealive.ai/api/", +]) +def test_oauth_provider_rejects_noncanonical_resource(resource): + with pytest.raises(ValueError, match="MCP_RESOURCE"): + build_oauth_provider(_config(mcp_resource=resource)) + + +def test_oauth_provider_allows_loopback_http_resource_for_local_development(): + provider = build_oauth_provider(_config(mcp_resource="http://127.0.0.1:8000/api")) + assert provider is not None + + +def test_oauth_enabled_rejects_same_mcp_and_tool_api_resource(): + with pytest.raises(ValueError, match="must be distinct"): + _config( + oauth_enabled=True, + tool_api_resource="https://MCP.codealive.ai/api", + ) + + +@pytest.mark.asyncio +async def test_protected_resource_metadata_and_challenge_are_exact(): + mcp = FastMCP("OAuth test", auth=build_oauth_provider(_config())) + app = mcp.http_app(path="/api", stateless_http=True) + async with app.router.lifespan_context(app): + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="https://mcp.codealive.ai", + ) as client: + metadata = await client.get("/.well-known/oauth-protected-resource/api") + unauthorized = await client.post("/api", json={}) + + assert metadata.status_code == 200 + assert metadata.headers["cache-control"] == "public, max-age=300" + assert metadata.json() == { + "resource": "https://mcp.codealive.ai/api", + "authorization_servers": ["https://auth.codealive.ai/"], + "scopes_supported": ["mcp:tools"], + "bearer_methods_supported": ["header"], + } + assert unauthorized.status_code == 401 + assert unauthorized.headers["www-authenticate"] == ( + 'Bearer realm="codealive-mcp", ' + 'resource_metadata="https://mcp.codealive.ai/.well-known/oauth-protected-resource/api", ' + 'scope="mcp:tools"' + ) + + +@pytest.mark.asyncio +async def test_verifier_rejects_extra_audience_and_missing_binding_claims(): + verifier = CodeAliveTokenVerifier(_config()) + verifier._jwt.verify_token = AsyncMock(return_value=AccessToken( + token="header.payload.signature", + client_id="client", + scopes=["mcp:tools"], + claims={ + "aud": ["https://mcp.codealive.ai/api", "https://other.example"], + "sub": "0123456789abcdef01234567", + "organisation_id": "1123456789abcdef01234567", + "mcp_connection_id": "2123456789abcdef01234567", + "client_id": "client", + }, + )) + assert await verifier.verify_token("header.payload.signature") is None + + verifier._jwt.verify_token.return_value.claims = { + "aud": "https://mcp.codealive.ai/api", + "sub": "0123456789abcdef01234567", + "organisation_id": "1123456789abcdef01234567", + "mcp_connection_id": "2123456789abcdef01234567", + "client_id": "client", + } + verified = await verifier.verify_token("header.payload.signature") + assert verified is not None + assert verified.subject == "0123456789abcdef01234567:2123456789abcdef01234567" + + verifier._jwt.verify_token.return_value.claims = {"aud": "https://mcp.codealive.ai/api"} + assert await verifier.verify_token("header.payload.signature") is None + + +@pytest.mark.asyncio +async def test_opaque_legacy_api_key_remains_supported(): + access = await CodeAliveTokenVerifier(_config()).verify_token( + "ca_1720000000000_0123456789abcdef0123456789abcdef0123456789a") + assert access is not None + assert access.client_id == "legacy-api-key" + assert access.scopes == ["mcp:tools"] + + +@pytest.mark.asyncio +async def test_malformed_bearer_never_falls_back_to_legacy_api_key(): + verifier = CodeAliveTokenVerifier(_config()) + verifier._jwt.verify_token = AsyncMock() + + assert await verifier.verify_token("arbitrary-bearer") is None + assert await verifier.verify_token("header.payload") is None + verifier._jwt.verify_token.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_token_exchange_uses_confidential_client_and_exact_resources(): + captured: httpx.Request | None = None + + async def handler(request: httpx.Request) -> httpx.Response: + nonlocal captured + captured = request + return httpx.Response(200, json={"access_token": "tool-token"}) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + token = await exchange_for_tool_token(client, _config(), "mcp-subject-token") + + assert token == "tool-token" + assert captured is not None + assert str(captured.url) == "https://auth.codealive.ai/connect/token" + body = captured.content.decode() + assert "subject_token=mcp-subject-token" in body + assert "resource=urn%3Acodealive%3Atool-api" in body + assert captured.headers["authorization"].startswith("Basic ") + + +@pytest.mark.asyncio +async def test_token_exchange_cache_coalesces_concurrent_calls_without_storing_subject_token(): + calls = 0 + + async def handler(_: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + await asyncio.sleep(0.02) + return httpx.Response(200, json={"access_token": "tool-token", "expires_in": 300}) + + cache = ToolTokenExchangeCache() + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + tokens = await asyncio.gather(*[ + exchange_for_tool_token(client, _config(), "mcp-subject-token", cache) + for _ in range(5) + ]) + repeated = await exchange_for_tool_token(client, _config(), "mcp-subject-token", cache) + + assert tokens == ["tool-token"] * 5 + assert repeated == "tool-token" + assert calls == 1 + assert all("mcp-subject-token" not in key for key in cache._entries) + + +@pytest.mark.asyncio +async def test_token_exchange_cache_invalidation_does_not_restore_stale_inflight_value(): + cache = ToolTokenExchangeCache() + stale_started = asyncio.Event() + release_stale = asyncio.Event() + + async def stale_factory(): + stale_started.set() + await release_stale.wait() + return "stale-token", float("inf") + + stale = asyncio.create_task(cache.get_or_create("key", stale_factory)) + await stale_started.wait() + await cache.invalidate("key") + fresh = await cache.get_or_create( + "key", + lambda: asyncio.sleep(0, result=("fresh-token", float("inf"))), + ) + release_stale.set() + + assert fresh == "fresh-token" + assert await stale == "stale-token" + assert await cache.get_or_create("key", stale_factory) == "fresh-token" + + +def test_oauth_enabled_treats_default_https_port_as_same_resource(): + with pytest.raises(ValueError, match="must be distinct"): + _config( + oauth_enabled=True, + tool_api_resource="https://mcp.codealive.ai:443/api", + ) diff --git a/src/tests/test_protocol_tools.py b/src/tests/test_protocol_tools.py index 293300d..539ae4e 100644 --- a/src/tests/test_protocol_tools.py +++ b/src/tests/test_protocol_tools.py @@ -15,7 +15,7 @@ @pytest.fixture(autouse=True) def _stdio_environment(monkeypatch): monkeypatch.setenv("TRANSPORT_MODE", "stdio") - monkeypatch.setenv("CODEALIVE_API_KEY", "test-key") + monkeypatch.setenv("CODEALIVE_API_KEY", "ca_1720000000000_0123456789abcdef0123456789abcdef0123456789a") monkeypatch.setenv("CODEALIVE_BASE_URL", "https://app.codealive.ai") diff --git a/src/tests/test_stdio_smoke.py b/src/tests/test_stdio_smoke.py index 65af9c3..2f777e8 100644 --- a/src/tests/test_stdio_smoke.py +++ b/src/tests/test_stdio_smoke.py @@ -86,7 +86,7 @@ async def test_stdio_server_lists_tools_and_uses_tool_api_v3_endpoint(): with _mock_codealive_server() as (port, requests): env = { **os.environ, - "CODEALIVE_API_KEY": "stdio-smoke-test-key", + "CODEALIVE_API_KEY": "ca_1720000000000_0123456789abcdef0123456789abcdef0123456789a", "CODEALIVE_BASE_URL": f"http://127.0.0.1:{port}/api", } server_params = StdioServerParameters( @@ -125,7 +125,7 @@ async def test_stdio_server_lists_tools_and_uses_tool_api_v3_endpoint(): assert requests == [ { "path": "/api/tools/get_data_sources", - "authorization": "Bearer stdio-smoke-test-key", + "authorization": "Bearer ca_1720000000000_0123456789abcdef0123456789abcdef0123456789a", "tool": "get_data_sources", "integration": "mcp", "client": "fastmcp-v3", diff --git a/src/tests/test_tool_api_v3.py b/src/tests/test_tool_api_v3.py index e791cbc..3eb2b20 100644 --- a/src/tests/test_tool_api_v3.py +++ b/src/tests/test_tool_api_v3.py @@ -7,6 +7,7 @@ from fastmcp.exceptions import ToolError from fastmcp.tools.tool import ToolResult +from core.config import Config from tools.artifact_query import get_artifact_query_schema, query_artifact_metadata from tools.artifact_relationships import get_artifact_relationships from tools.chat import chat @@ -14,6 +15,9 @@ from tools.fetch_artifacts import fetch_artifacts from tools.repository import get_file_tree, get_repository_ontology, read_file from tools.search import grep_search, semantic_search +from tools.tool_api import call_tool_api + +LEGACY_API_KEY = "ca_1720000000000_0123456789abcdef0123456789abcdef0123456789a" def _context_with_response( @@ -137,7 +141,7 @@ def _context_with_response( ) @patch("tools.tool_api.get_api_key_from_context") async def test_mcp_tools_post_canonical_v3_payloads(mock_get_api_key, tool_call, expected_path, expected_payload): - mock_get_api_key.return_value = "test_key" + mock_get_api_key.return_value = LEGACY_API_KEY ctx, client = _context_with_response("done") result = await tool_call(ctx) @@ -146,7 +150,7 @@ async def test_mcp_tools_post_canonical_v3_payloads(mock_get_api_key, tool_call, call_args = client.post.call_args assert call_args.args[0] == expected_path assert call_args.kwargs["json"] == {**expected_payload, "output_format": "agentic"} - assert call_args.kwargs["headers"]["Authorization"] == "Bearer test_key" + assert call_args.kwargs["headers"]["Authorization"] == f"Bearer {LEGACY_API_KEY}" assert call_args.kwargs["headers"]["X-CodeAlive-Integration"] == "mcp" assert call_args.kwargs["headers"]["X-CodeAlive-Tool"] == expected_path.rsplit("/", 1)[1] assert call_args.kwargs["headers"]["X-CodeAlive-Client"] == "fastmcp-v3" @@ -184,7 +188,7 @@ async def test_local_bounds_validation_fail_before_network_call(): @pytest.mark.asyncio @patch("tools.tool_api.get_api_key_from_context") async def test_repairable_backend_error_sets_native_mcp_error(mock_get_api_key): - mock_get_api_key.return_value = "test_key" + mock_get_api_key.return_value = LEGACY_API_KEY error = { "code": "invalid_tool_arguments", "message": "question is required", @@ -203,3 +207,58 @@ async def test_repairable_backend_error_sets_native_mcp_error(mock_get_api_key): assert len(result.content) == 1 assert result.content[0].text == "invalid_tool_arguments" assert result.structured_content == {"error": error} + + +@pytest.mark.asyncio +@patch("tools.tool_api.invalidate_tool_token_exchange", new_callable=AsyncMock) +@patch("tools.tool_api.exchange_for_tool_token", new_callable=AsyncMock) +@patch("tools.tool_api.get_api_key_from_context") +async def test_oauth_tool_call_evicts_rejected_exchange_and_retries_once( + mock_get_api_key, + mock_exchange, + mock_invalidate, +): + mock_get_api_key.return_value = "header.payload.signature" + mock_exchange.side_effect = ["stale-tool-token", "fresh-tool-token"] + ctx, client = _context_with_response("done") + context = ctx.request_context.lifespan_context + context.config = MagicMock() + context.tool_token_cache = MagicMock() + + unauthorized = MagicMock(status_code=401) + success = MagicMock(status_code=200) + success.json.return_value = {"rendered": "done", "obj": {"ok": True}} + success.raise_for_status = MagicMock() + client.post.side_effect = [unauthorized, success] + + result = await call_tool_api(ctx, "semantic_search", {"question": "why"}) + + assert result == "done" + assert client.post.call_count == 2 + assert client.post.call_args_list[0].kwargs["headers"]["Authorization"] == "Bearer stale-tool-token" + assert client.post.call_args_list[1].kwargs["headers"]["Authorization"] == "Bearer fresh-tool-token" + mock_invalidate.assert_awaited_once_with( + context.tool_token_cache, + context.config, + "header.payload.signature", + ) + + +@pytest.mark.asyncio +@patch("tools.tool_api.exchange_for_tool_token", new_callable=AsyncMock) +@patch("tools.tool_api.get_api_key_from_context") +async def test_oauth_shaped_credential_is_not_exchanged_when_feature_is_disabled( + mock_get_api_key, + mock_exchange, +): + credential = "header.payload.signature" + mock_get_api_key.return_value = credential + ctx, client = _context_with_response("done") + context = ctx.request_context.lifespan_context + context.config = Config(oauth_enabled=False) + + result = await call_tool_api(ctx, "semantic_search", {"question": "why"}) + + assert result == "done" + assert client.post.call_args.kwargs["headers"]["Authorization"] == f"Bearer {credential}" + mock_exchange.assert_not_awaited() diff --git a/src/tools/tool_api.py b/src/tools/tool_api.py index b9f747e..e943d2f 100644 --- a/src/tools/tool_api.py +++ b/src/tools/tool_api.py @@ -9,7 +9,16 @@ from fastmcp.exceptions import ToolError from fastmcp.tools.tool import ToolResult -from core import CodeAliveContext, get_api_key_from_context, log_api_request, log_api_response +from core import ( + CodeAliveContext, + Config, + exchange_for_tool_token, + get_api_key_from_context, + invalidate_tool_token_exchange, + is_oauth_credential, + log_api_request, + log_api_response, +) from utils import handle_api_error ToolApiResult = str | ToolResult @@ -54,10 +63,20 @@ async def call_tool_api( action_label: Optional[str] = None, ) -> ToolApiResult: context: CodeAliveContext = ctx.request_context.lifespan_context - api_key = get_api_key_from_context(ctx) + inbound_credential = get_api_key_from_context(ctx) + config = context.config or Config.from_environment() + oauth_credential = config.oauth_enabled and is_oauth_credential(inbound_credential) + outbound_credential = inbound_credential + if oauth_credential: + outbound_credential = await exchange_for_tool_token( + context.client, + config, + inbound_credential, + context.tool_token_cache, + ) body = {**omit_empty(payload), "output_format": "agentic"} headers = { - "Authorization": f"Bearer {api_key}", + "Authorization": f"Bearer {outbound_credential}", "X-CodeAlive-Integration": "mcp", "X-CodeAlive-Tool": tool_name, "X-CodeAlive-Client": "fastmcp-v3", @@ -69,6 +88,20 @@ async def call_tool_api( try: response = await context.client.post(endpoint, json=body, headers=headers) + if oauth_credential and response.status_code == 401: + await invalidate_tool_token_exchange( + context.tool_token_cache, + config, + inbound_credential, + ) + outbound_credential = await exchange_for_tool_token( + context.client, + config, + inbound_credential, + context.tool_token_cache, + ) + headers = {**headers, "Authorization": f"Bearer {outbound_credential}"} + response = await context.client.post(endpoint, json=body, headers=headers) log_api_response(response, request_id) response.raise_for_status() data = response.json() From f519a5d4e3ccb578e4866694474a7c7a71ca19fe Mon Sep 17 00:00:00 2001 From: Rodion Mostovoi <36400912+rodion-m@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:07:19 +0500 Subject: [PATCH 2/5] fix: preserve shared token exchanges after cancellation --- src/core/oauth.py | 34 +++++++++++++++++++++++++--------- src/tests/test_oauth.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/src/core/oauth.py b/src/core/oauth.py index 490b161..26382c0 100644 --- a/src/core/oauth.py +++ b/src/core/oauth.py @@ -206,26 +206,42 @@ async def get_or_create( generation = self._generation inflight = self._inflight.get(key) if inflight is None: - task = asyncio.create_task(factory()) + task = asyncio.create_task( + self._run_factory(key, generation, factory) + ) self._inflight[key] = (task, generation) else: task, generation = inflight + token, _ = await asyncio.shield(task) + return token + + async def _run_factory( + self, + key: str, + generation: int, + factory: Callable[[], Awaitable[tuple[str, float]]], + ) -> tuple[str, float]: + task = asyncio.current_task() try: - token, cache_until = await asyncio.shield(task) - finally: - async with self._lock: - if self._inflight.get(key) == (task, generation): - self._inflight.pop(key, None) + token, cache_until = await factory() + if cache_until <= time.monotonic(): + return token, cache_until - if cache_until > time.monotonic(): async with self._lock: - if self._generation == generation: + if ( + self._generation == generation + and self._inflight.get(key) == (task, generation) + ): self._entries[key] = (token, cache_until) self._entries.move_to_end(key) while len(self._entries) > self._maximum_entries: self._entries.popitem(last=False) - return token + return token, cache_until + finally: + async with self._lock: + if self._inflight.get(key) == (task, generation): + self._inflight.pop(key, None) async def invalidate(self, key: str) -> None: """Remove a rejected token without exposing the subject token in cache state.""" diff --git a/src/tests/test_oauth.py b/src/tests/test_oauth.py index 895ffe5..2c9a1bf 100644 --- a/src/tests/test_oauth.py +++ b/src/tests/test_oauth.py @@ -215,6 +215,34 @@ async def stale_factory(): assert await cache.get_or_create("key", stale_factory) == "fresh-token" +@pytest.mark.asyncio +async def test_token_exchange_cache_caller_cancellation_keeps_shared_exchange_alive(): + cache = ToolTokenExchangeCache() + exchange_started = asyncio.Event() + release_exchange = asyncio.Event() + calls = 0 + + async def factory(): + nonlocal calls + calls += 1 + exchange_started.set() + await release_exchange.wait() + return "tool-token", float("inf") + + cancelled_caller = asyncio.create_task(cache.get_or_create("key", factory)) + await exchange_started.wait() + cancelled_caller.cancel() + with pytest.raises(asyncio.CancelledError): + await cancelled_caller + + surviving_caller = asyncio.create_task(cache.get_or_create("key", factory)) + release_exchange.set() + + assert await surviving_caller == "tool-token" + assert await cache.get_or_create("key", factory) == "tool-token" + assert calls == 1 + + def test_oauth_enabled_treats_default_https_port_as_same_resource(): with pytest.raises(ValueError, match="must be distinct"): _config( From a424ea1937f69d5b7b6ee462cbcb4789ca77dfc5 Mon Sep 17 00:00:00 2001 From: Rodion Mostovoi <36400912+rodion-m@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:24:40 +0500 Subject: [PATCH 3/5] fix: validate OAuth deployment identifiers --- README.md | 4 ++++ src/core/config.py | 15 ++++++++++++++- src/tests/test_oauth.py | 21 +++++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 38e9c80..05b0dad 100644 --- a/README.md +++ b/README.md @@ -308,6 +308,10 @@ The authorization server and MCP service values must match exactly. In CodeAlive corresponding settings live under `McpOAuth` (`Enabled`, `Issuer`, `Resource`, `ToolApiResource`, `InternalClientId`, and `InternalClientSecret`). Persist the Web.Server Data Protection key ring and OpenIddict signing/encryption certificates across replicas and restarts. +For a zero-downtime internal credential rotation, give the new credential a new client ID, deploy +Web.Server with both current and `PreviousInternalClientId`/`PreviousInternalClientSecret`, roll +MCP replicas to the new current pair, then remove the previous pair. Web.Server deliberately fails +startup instead of changing a secret in place under an existing client ID. Enable the Web.Server and MCP flags in the same rollout; a half-enabled deployment is not a valid steady state. API-key credentials retain their explicit legacy grammar and are never used as a fallback after OAuth validation fails. diff --git a/src/core/config.py b/src/core/config.py index 42908e3..c0189cb 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -74,6 +74,13 @@ def _same_resource_identifier(left_value: str, right_value: str) -> bool: return left.path == right.path and left.query == right.query and left.fragment == right.fragment +def _is_absolute_resource_identifier(value: str) -> bool: + if not value or value != value.strip(): + return False + parsed = urlsplit(value) + return bool(parsed.scheme) and bool(parsed.netloc or parsed.path) + + def normalize_base_url(base_url: Optional[str]) -> str: """Normalize a CodeAlive base URL to the deployment origin. @@ -116,10 +123,16 @@ class Config: def __post_init__(self) -> None: if self.oauth_enabled: validate_oauth_urls(self.oauth_issuer, self.mcp_resource) + if not _is_absolute_resource_identifier(self.tool_api_resource): + raise ValueError( + "CODEALIVE_TOOL_API_RESOURCE must be an absolute resource identifier" + ) if _same_resource_identifier(self.mcp_resource, self.tool_api_resource): raise ValueError( "CODEALIVE_MCP_RESOURCE and CODEALIVE_TOOL_API_RESOURCE must be distinct" ) + if not self.oauth_internal_client_id or not self.oauth_internal_client_id.strip(): + raise ValueError("CODEALIVE_OAUTH_INTERNAL_CLIENT_ID must not be empty") @classmethod def from_environment(cls) -> "Config": @@ -133,7 +146,7 @@ def from_environment(cls) -> "Config": oauth_enabled=os.environ.get("CODEALIVE_MCP_OAUTH_ENABLED", "false").lower() in ["true", "1", "yes"], oauth_issuer=os.environ.get("CODEALIVE_OAUTH_ISSUER", "https://auth.codealive.ai/"), mcp_resource=os.environ.get("CODEALIVE_MCP_RESOURCE", "https://mcp.codealive.ai/api"), - tool_api_resource=os.environ.get("CODEALIVE_TOOL_API_RESOURCE", "urn:codealive:tool-api").rstrip("/"), + tool_api_resource=os.environ.get("CODEALIVE_TOOL_API_RESOURCE", "urn:codealive:tool-api"), oauth_internal_client_id=os.environ.get("CODEALIVE_OAUTH_INTERNAL_CLIENT_ID", "codealive-mcp"), oauth_internal_client_secret=os.environ.get("CODEALIVE_OAUTH_INTERNAL_CLIENT_SECRET"), ) diff --git a/src/tests/test_oauth.py b/src/tests/test_oauth.py index 2c9a1bf..a3ec2a9 100644 --- a/src/tests/test_oauth.py +++ b/src/tests/test_oauth.py @@ -66,6 +66,27 @@ def test_oauth_enabled_rejects_same_mcp_and_tool_api_resource(): ) +@pytest.mark.parametrize("tool_resource", ["", "relative/resource", " urn:codealive:tool-api"]) +def test_oauth_enabled_rejects_non_absolute_tool_api_resource(tool_resource): + with pytest.raises(ValueError, match="TOOL_API_RESOURCE"): + _config(oauth_enabled=True, tool_api_resource=tool_resource) + + +@pytest.mark.parametrize("client_id", ["", " "]) +def test_oauth_enabled_rejects_empty_internal_client_id(client_id): + with pytest.raises(ValueError, match="INTERNAL_CLIENT_ID"): + _config(oauth_enabled=True, oauth_internal_client_id=client_id) + + +def test_oauth_environment_does_not_silently_rewrite_tool_api_audience(monkeypatch): + monkeypatch.setenv("CODEALIVE_MCP_OAUTH_ENABLED", "true") + monkeypatch.setenv("CODEALIVE_TOOL_API_RESOURCE", "https://tools.example/api/") + + config = Config.from_environment() + + assert config.tool_api_resource == "https://tools.example/api/" + + @pytest.mark.asyncio async def test_protected_resource_metadata_and_challenge_are_exact(): mcp = FastMCP("OAuth test", auth=build_oauth_provider(_config())) From 692e1047df2ace5fc3f1ceef8e08a781a59cf879 Mon Sep 17 00:00:00 2001 From: Rodion Mostovoi <36400912+rodion-m@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:50:53 +0500 Subject: [PATCH 4/5] docs: explain cloud MCP OAuth login --- README.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 05b0dad..4e8b8fc 100644 --- a/README.md +++ b/README.md @@ -279,7 +279,9 @@ curl http://localhost:8000/health 1. **For CodeAlive Cloud (default):** - Remove `CODEALIVE_BASE_URL` environment variable (uses default `https://app.codealive.ai`) - - Clients must provide their API key via `Authorization: Bearer YOUR_KEY` header + - For remote clients with OAuth support, configure only `https://mcp.codealive.ai/api` and + complete the browser sign-in when prompted + - Existing API-key clients remain supported via `Authorization: Bearer YOUR_KEY` 2. **For Self-Hosted CodeAlive:** - Set `CODEALIVE_BASE_URL` to your CodeAlive instance URL (e.g., `https://codealive.yourcompany.com`) @@ -288,6 +290,21 @@ curl http://localhost:8000/health See `docker-compose.example.yml` for the complete configuration template. +For example, current Codex and Claude Code clients can use browser OAuth without storing a +CodeAlive API key: + +```bash +codex mcp add codealive --url https://mcp.codealive.ai/api +codex mcp login codealive + +claude mcp add --transport http codealive https://mcp.codealive.ai/api +# Start Claude Code and run /mcp to authenticate. +``` + +Cursor and OpenCode also discover OAuth automatically from the same URL. Use +`cursor-agent mcp login codealive` or `opencode mcp auth codealive` when their UI does not prompt +automatically. API-key configuration remains available as a compatibility option. + ### OAuth 2.1 deployment profile Remote HTTP deployments can enable browser authorization while keeping legacy API-key clients From 64c0186a999ddbea7eb50462ea75635af52a0fd0 Mon Sep 17 00:00:00 2001 From: Rodion Mostovoi <36400912+rodion-m@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:57:12 +0500 Subject: [PATCH 5/5] fix: parse OAuth challenges in linear time --- src/core/oauth.py | 40 +++++++++++++++++++++++++++++++++++----- src/tests/test_oauth.py | 7 +++++-- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/src/core/oauth.py b/src/core/oauth.py index 8d3fcae..cec9432 100644 --- a/src/core/oauth.py +++ b/src/core/oauth.py @@ -41,6 +41,37 @@ def is_oauth_credential(token: str) -> bool: return not is_legacy_api_key(token) +def _bearer_auth_parameters(challenge: str) -> list[tuple[str, str]]: + """Split server-generated Bearer parameters without regex backtracking.""" + value = challenge.strip() + if not value.lower().startswith("bearer"): + return [] + value = value[6:].lstrip() + parameters: list[tuple[str, str]] = [] + start = 0 + quoted = False + escaped = False + for index, character in enumerate(value): + if escaped: + escaped = False + elif quoted and character == "\\": + escaped = True + elif character == '"': + quoted = not quoted + elif character == "," and not quoted: + segment = value[start:index].strip() + name, separator, _ = segment.partition("=") + if separator and name.strip(): + parameters.append((name.strip().lower(), segment)) + start = index + 1 + + segment = value[start:].strip() + name, separator, _ = segment.partition("=") + if separator and name.strip(): + parameters.append((name.strip().lower(), segment)) + return parameters + + class CodeAliveTokenVerifier(TokenVerifier): """Accept legacy opaque API keys or strictly validate CodeAlive MCP JWTs.""" @@ -132,16 +163,15 @@ async def send_with_challenge(message): # then add the MCP discovery attributes missing from its challenge. preserved_attributes: list[str] = [] if existing_challenge: - for match in re.finditer( - r'([A-Za-z][A-Za-z0-9_-]*)=(?:"(?:\\.|[^"])*"|[^,\s]+)', - existing_challenge, + for name, parameter in _bearer_auth_parameters( + existing_challenge ): - if match.group(1).lower() not in { + if name not in { "realm", "resource_metadata", "scope", }: - preserved_attributes.append(match.group(0)) + preserved_attributes.append(parameter) attributes = [ 'realm="codealive-mcp"', diff --git a/src/tests/test_oauth.py b/src/tests/test_oauth.py index e9ff055..fa77104 100644 --- a/src/tests/test_oauth.py +++ b/src/tests/test_oauth.py @@ -200,7 +200,7 @@ async def rejected_app(scope, receive, send): headers={ "WWW-Authenticate": ( 'Bearer error="invalid_token", ' - 'error_description="The access token expired"' + 'error_description="The access token expired, sign in again"' ) }, ) @@ -222,7 +222,10 @@ async def rejected_app(scope, receive, send): challenge = response.headers["www-authenticate"] assert 'error="invalid_token"' in challenge - assert 'error_description="The access token expired"' in challenge + assert ( + 'error_description="The access token expired, sign in again"' + in challenge + ) assert 'resource_metadata="https://mcp.codealive.ai/' in challenge assert 'scope="mcp:tools"' in challenge