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