Skip to content

Commit f86892b

Browse files
authored
Add OAuth 2.1 resource-server support (#23)
* feat: add OAuth 2.1 support * fix: preserve shared token exchanges after cancellation * fix: validate OAuth deployment identifiers * docs: explain cloud MCP OAuth login * fix: parse OAuth challenges in linear time
1 parent e335d00 commit f86892b

12 files changed

Lines changed: 1251 additions & 28 deletions

README.md

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,10 @@ The equivalent repeatable CLI options are `--allowed-host` and
206206
After making changes, quickly verify everything works:
207207

208208
```bash
209+
# Match pyproject.toml exactly; older uv versions reject the locked setup.
210+
uv --version # expected: uv 0.11.28
211+
uv sync --locked --extra test
212+
209213
# Install the repository pre-push dependency audit once per clone
210214
./scripts/setup-hooks.sh
211215

@@ -223,6 +227,9 @@ make unit-test
223227

224228
# Run all tests
225229
make test
230+
231+
# Equivalent direct locked test run
232+
uv run pytest src/tests/ -q
226233
```
227234

228235
The smoke test verifies:
@@ -272,7 +279,9 @@ curl http://localhost:8000/health
272279

273280
1. **For CodeAlive Cloud (default):**
274281
- Remove `CODEALIVE_BASE_URL` environment variable (uses default `https://app.codealive.ai`)
275-
- Clients must provide their API key via `Authorization: Bearer YOUR_KEY` header
282+
- For remote clients with OAuth support, configure only `https://mcp.codealive.ai/api` and
283+
complete the browser sign-in when prompted
284+
- Existing API-key clients remain supported via `Authorization: Bearer YOUR_KEY`
276285

277286
2. **For Self-Hosted CodeAlive:**
278287
- Set `CODEALIVE_BASE_URL` to your CodeAlive instance URL (e.g., `https://codealive.yourcompany.com`)
@@ -281,6 +290,49 @@ curl http://localhost:8000/health
281290

282291
See `docker-compose.example.yml` for the complete configuration template.
283292

293+
For example, current Codex and Claude Code clients can use browser OAuth without storing a
294+
CodeAlive API key:
295+
296+
```bash
297+
codex mcp add codealive --url https://mcp.codealive.ai/api
298+
codex mcp login codealive
299+
300+
claude mcp add --transport http codealive https://mcp.codealive.ai/api
301+
# Start Claude Code and run /mcp to authenticate.
302+
```
303+
304+
Cursor and OpenCode also discover OAuth automatically from the same URL. Use
305+
`cursor-agent mcp login codealive` or `opencode mcp auth codealive` when their UI does not prompt
306+
automatically. API-key configuration remains available as a compatibility option.
307+
308+
### OAuth 2.1 deployment profile
309+
310+
Remote HTTP deployments can enable browser authorization while keeping legacy API-key clients
311+
working during rollout. OAuth mode publishes MCP Protected Resource Metadata, validates exact
312+
issuer/resource-bound JWTs, and exchanges them for a separate short-lived Tool API token. The
313+
incoming MCP bearer token is never forwarded downstream.
314+
315+
| Environment variable | Purpose |
316+
|---|---|
317+
| `CODEALIVE_MCP_OAUTH_ENABLED=true` | Enables OAuth validation and MCP authorization discovery for HTTP transport |
318+
| `CODEALIVE_OAUTH_ISSUER` | Exact OpenIddict issuer, with a trailing slash |
319+
| `CODEALIVE_MCP_RESOURCE` | Exact public MCP resource URL; its path is also the HTTP MCP path |
320+
| `CODEALIVE_TOOL_API_RESOURCE` | Downstream audience; defaults to `urn:codealive:tool-api` |
321+
| `CODEALIVE_OAUTH_INTERNAL_CLIENT_ID` | Confidential resource-server client used only for token exchange |
322+
| `CODEALIVE_OAUTH_INTERNAL_CLIENT_SECRET` | Required secret for that internal client; startup fails closed when it is missing |
323+
324+
The authorization server and MCP service values must match exactly. In CodeAlive Web.Server the
325+
corresponding settings live under `McpOAuth` (`Enabled`, `Issuer`, `Resource`,
326+
`ToolApiResource`, `InternalClientId`, and `InternalClientSecret`). Persist the Web.Server Data
327+
Protection key ring and OpenIddict signing/encryption certificates across replicas and restarts.
328+
For a zero-downtime internal credential rotation, give the new credential a new client ID, deploy
329+
Web.Server with both current and `PreviousInternalClientId`/`PreviousInternalClientSecret`, roll
330+
MCP replicas to the new current pair, then remove the previous pair. Web.Server deliberately fails
331+
startup instead of changing a secret in place under an existing client ID.
332+
Enable the Web.Server and MCP flags in the same rollout; a half-enabled deployment is not a valid
333+
steady state. API-key credentials retain their explicit legacy grammar and are never used as a
334+
fallback after OAuth validation fails.
335+
284336
### Connecting MCP Clients to Your Deployed Instance
285337

286338
Use the same generic connection details as CodeAlive Cloud, replacing the endpoint with your deployment's `/api` URL:

src/codealive_mcp_server.py

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@
1010
import sys
1111
from importlib.metadata import PackageNotFoundError, version
1212
from pathlib import Path
13+
from urllib.parse import urlsplit
1314

1415
from dotenv import load_dotenv
1516
from fastmcp import FastMCP
1617
from loguru import logger
18+
from starlette.middleware import Middleware
1719
from starlette.requests import Request
1820
from starlette.responses import JSONResponse
1921

@@ -26,7 +28,7 @@
2628
sys.path.insert(0, str(Path(__file__).parent))
2729

2830
# Import core components
29-
from core import codealive_lifespan, setup_logging, setup_debug_logging, init_tracing, normalize_base_url, _server_ready
31+
from core import Config, MetadataAwareHostOriginGuardMiddleware, build_oauth_provider, codealive_lifespan, setup_logging, setup_debug_logging, init_tracing, normalize_base_url, _server_ready
3032
import core.client as _client_module # for /ready flag access
3133
from middleware import N8NRemoveParametersMiddleware, ObservabilityMiddleware
3234
from tools import (
@@ -206,13 +208,11 @@ def main():
206208
os.environ["CODEALIVE_BASE_URL"] = normalized_base_url
207209
logger.info("Using base URL from command line: {url}", url=normalized_base_url)
208210

209-
# Disable SSL verification if explicitly requested or in debug mode
210-
if args.ignore_ssl or debug:
211+
# Debug logging must not weaken transport security. TLS verification is disabled only
212+
# through the explicit opt-in flag used for local self-signed development endpoints.
213+
if args.ignore_ssl:
211214
os.environ["CODEALIVE_IGNORE_SSL"] = "true"
212-
if args.ignore_ssl:
213-
logger.warning("SSL certificate validation disabled by --ignore-ssl flag")
214-
elif debug:
215-
logger.warning("SSL certificate validation disabled in debug mode")
215+
logger.warning("SSL certificate validation disabled by --ignore-ssl flag")
216216

217217
if debug:
218218
logger.debug(
@@ -247,13 +247,24 @@ def main():
247247
)
248248
logger.info("HTTP mode: API keys extracted from Authorization: Bearer headers")
249249

250+
oauth_config = Config.from_environment()
251+
if oauth_config.oauth_enabled:
252+
if not oauth_config.oauth_internal_client_secret:
253+
logger.error(
254+
"OAuth mode requires CODEALIVE_OAUTH_INTERNAL_CLIENT_SECRET for downstream token exchange"
255+
)
256+
sys.exit(1)
257+
mcp.auth = build_oauth_provider(oauth_config)
258+
250259
if not base_url:
251260
logger.info(
252261
"CODEALIVE_BASE_URL not set, using default: https://app.codealive.ai"
253262
)
254263

255264
# Run the server with the selected transport
256265
if args.transport == "http":
266+
oauth_config = Config.from_environment()
267+
mcp_path = urlsplit(oauth_config.mcp_resource).path or "/api"
257268
allowed_hosts = args.allowed_host or [
258269
value.strip()
259270
for value in os.getenv("CODEALIVE_MCP_ALLOWED_HOSTS", "").split(",")
@@ -264,14 +275,28 @@ def main():
264275
for value in os.getenv("CODEALIVE_MCP_ALLOWED_ORIGINS", "").split(",")
265276
if value.strip()
266277
]
278+
transport_middleware = None
279+
host_origin_protection = True
280+
if oauth_config.oauth_enabled:
281+
metadata_path = f"/.well-known/oauth-protected-resource{mcp_path}"
282+
transport_middleware = [
283+
Middleware(
284+
MetadataAwareHostOriginGuardMiddleware,
285+
metadata_path=metadata_path,
286+
allowed_hosts=allowed_hosts or None,
287+
allowed_origins=allowed_origins or None,
288+
)
289+
]
290+
host_origin_protection = False
267291
# Use /api path to avoid conflicts with health endpoint
268292
mcp.run(
269293
transport="http",
270294
host=args.host,
271295
port=args.port,
272-
path="/api",
296+
path=mcp_path,
273297
stateless_http=True,
274-
host_origin_protection=True,
298+
middleware=transport_middleware,
299+
host_origin_protection=host_origin_protection,
275300
allowed_hosts=allowed_hosts or None,
276301
allowed_origins=allowed_origins or None,
277302
uvicorn_config={

src/core/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,29 @@
11
"""Core components for CodeAlive MCP server."""
22

33
from .client import CodeAliveContext, get_api_key_from_context, codealive_lifespan, _server_ready
4+
from .oauth import (
5+
MetadataAwareHostOriginGuardMiddleware,
6+
ToolTokenExchangeCache,
7+
build_oauth_provider,
8+
exchange_for_tool_token,
9+
invalidate_tool_token_exchange,
10+
is_oauth_credential,
11+
is_jwt_shaped,
12+
)
413
from .config import Config, REQUEST_TIMEOUT_SECONDS, normalize_base_url
514
from .logging import setup_logging, setup_debug_logging, log_api_request, log_api_response
615
from .observability import init_tracing
716

817
__all__ = [
918
'CodeAliveContext',
19+
'MetadataAwareHostOriginGuardMiddleware',
1020
'get_api_key_from_context',
21+
'build_oauth_provider',
22+
'exchange_for_tool_token',
23+
'invalidate_tool_token_exchange',
24+
'is_oauth_credential',
25+
'ToolTokenExchangeCache',
26+
'is_jwt_shaped',
1127
'codealive_lifespan',
1228
'Config',
1329
'REQUEST_TIMEOUT_SECONDS',

src/core/client.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from loguru import logger
1212

1313
from .config import Config, REQUEST_TIMEOUT_SECONDS
14+
from .oauth import ToolTokenExchangeCache
1415

1516

1617
@dataclass
@@ -19,6 +20,8 @@ class CodeAliveContext:
1920
client: httpx.AsyncClient
2021
api_key: str
2122
base_url: str
23+
config: Config | None = None
24+
tool_token_cache: ToolTokenExchangeCache | None = None
2225

2326

2427
# Module-level readiness state for the /ready endpoint.
@@ -96,8 +99,10 @@ async def codealive_lifespan(server: FastMCP) -> AsyncIterator[CodeAliveContext]
9699
yield CodeAliveContext(
97100
client=client,
98101
api_key="", # Will be set per-request in HTTP mode
99-
base_url=config.base_url
102+
base_url=config.base_url,
103+
config=config,
104+
tool_token_cache=ToolTokenExchangeCache(),
100105
)
101106
finally:
102107
_server_ready = False
103-
await client.aclose()
108+
await client.aclose()

src/core/config.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Configuration management for CodeAlive MCP server."""
22

33
import os
4+
import ipaddress
45
from dataclasses import dataclass
56
from typing import Optional
67
from urllib.parse import urlsplit, urlunsplit
@@ -9,6 +10,77 @@
910
REQUEST_TIMEOUT_SECONDS = 300.0
1011

1112

13+
def _is_loopback_host(host: str | None) -> bool:
14+
if host is None:
15+
return False
16+
if host.lower() == "localhost":
17+
return True
18+
try:
19+
return ipaddress.ip_address(host).is_loopback
20+
except ValueError:
21+
return False
22+
23+
24+
def validate_oauth_urls(issuer_value: str, resource_value: str) -> None:
25+
issuer = urlsplit(issuer_value)
26+
if (
27+
issuer.scheme != "https"
28+
or not issuer.netloc
29+
or issuer.username is not None
30+
or issuer.password is not None
31+
or issuer.path not in {"", "/"}
32+
or issuer.query
33+
or issuer.fragment
34+
or not issuer_value.endswith("/")
35+
or (issuer.hostname or "").endswith(".")
36+
):
37+
raise ValueError("CODEALIVE_OAUTH_ISSUER must be a canonical HTTPS origin")
38+
39+
resource = urlsplit(resource_value)
40+
secure = resource.scheme == "https" or (
41+
resource.scheme == "http" and _is_loopback_host(resource.hostname)
42+
)
43+
if (
44+
not secure
45+
or not resource.netloc
46+
or resource.username is not None
47+
or resource.password is not None
48+
or resource.path in {"", "/"}
49+
or resource.query
50+
or resource.fragment
51+
or resource_value.endswith("/")
52+
or (resource.hostname or "").endswith(".")
53+
):
54+
raise ValueError("CODEALIVE_MCP_RESOURCE must be a canonical HTTPS URL with a path")
55+
56+
57+
def _same_resource_identifier(left_value: str, right_value: str) -> bool:
58+
left = urlsplit(left_value)
59+
right = urlsplit(right_value)
60+
if left.scheme.lower() != right.scheme.lower():
61+
return False
62+
if left.netloc or right.netloc:
63+
left_port = left.port or (443 if left.scheme.lower() == "https" else 80 if left.scheme.lower() == "http" else None)
64+
right_port = right.port or (443 if right.scheme.lower() == "https" else 80 if right.scheme.lower() == "http" else None)
65+
return (
66+
left.hostname == right.hostname
67+
and left_port == right_port
68+
and left.username == right.username
69+
and left.password == right.password
70+
and left.path == right.path
71+
and left.query == right.query
72+
and left.fragment == right.fragment
73+
)
74+
return left.path == right.path and left.query == right.query and left.fragment == right.fragment
75+
76+
77+
def _is_absolute_resource_identifier(value: str) -> bool:
78+
if not value or value != value.strip():
79+
return False
80+
parsed = urlsplit(value)
81+
return bool(parsed.scheme) and bool(parsed.netloc or parsed.path)
82+
83+
1284
def normalize_base_url(base_url: Optional[str]) -> str:
1385
"""Normalize a CodeAlive base URL to the deployment origin.
1486
@@ -41,6 +113,26 @@ class Config:
41113
transport_mode: str = "stdio"
42114
verify_ssl: bool = True
43115
debug_mode: bool = False
116+
oauth_enabled: bool = False
117+
oauth_issuer: str = "https://auth.codealive.ai/"
118+
mcp_resource: str = "https://mcp.codealive.ai/api"
119+
tool_api_resource: str = "urn:codealive:tool-api"
120+
oauth_internal_client_id: str = "codealive-mcp"
121+
oauth_internal_client_secret: Optional[str] = None
122+
123+
def __post_init__(self) -> None:
124+
if self.oauth_enabled:
125+
validate_oauth_urls(self.oauth_issuer, self.mcp_resource)
126+
if not _is_absolute_resource_identifier(self.tool_api_resource):
127+
raise ValueError(
128+
"CODEALIVE_TOOL_API_RESOURCE must be an absolute resource identifier"
129+
)
130+
if _same_resource_identifier(self.mcp_resource, self.tool_api_resource):
131+
raise ValueError(
132+
"CODEALIVE_MCP_RESOURCE and CODEALIVE_TOOL_API_RESOURCE must be distinct"
133+
)
134+
if not self.oauth_internal_client_id or not self.oauth_internal_client_id.strip():
135+
raise ValueError("CODEALIVE_OAUTH_INTERNAL_CLIENT_ID must not be empty")
44136

45137
@classmethod
46138
def from_environment(cls) -> "Config":
@@ -51,4 +143,10 @@ def from_environment(cls) -> "Config":
51143
transport_mode=os.environ.get("TRANSPORT_MODE", "stdio"),
52144
verify_ssl=not os.environ.get("CODEALIVE_IGNORE_SSL", "").lower() in ["true", "1", "yes"],
53145
debug_mode=os.environ.get("DEBUG_MODE", "").lower() in ["true", "1", "yes"],
146+
oauth_enabled=os.environ.get("CODEALIVE_MCP_OAUTH_ENABLED", "false").lower() in ["true", "1", "yes"],
147+
oauth_issuer=os.environ.get("CODEALIVE_OAUTH_ISSUER", "https://auth.codealive.ai/"),
148+
mcp_resource=os.environ.get("CODEALIVE_MCP_RESOURCE", "https://mcp.codealive.ai/api"),
149+
tool_api_resource=os.environ.get("CODEALIVE_TOOL_API_RESOURCE", "urn:codealive:tool-api"),
150+
oauth_internal_client_id=os.environ.get("CODEALIVE_OAUTH_INTERNAL_CLIENT_ID", "codealive-mcp"),
151+
oauth_internal_client_secret=os.environ.get("CODEALIVE_OAUTH_INTERNAL_CLIENT_SECRET"),
54152
)

0 commit comments

Comments
 (0)