Add MCP OAuth auth mode via token introspection

master
oabrivard 6 days ago
parent df577e62b4
commit 0b9886fc56

@ -36,4 +36,11 @@ A2A_PUBLIC_BASE_URL=
A2A_AGENT_NAME=Personal Agent A2A_AGENT_NAME=Personal Agent
A2A_AGENT_DESCRIPTION=Personal productivity agent for calendar availability and email operations. A2A_AGENT_DESCRIPTION=Personal productivity agent for calendar availability and email operations.
MCP_ENABLE_MUTATION_TOOLS=false MCP_ENABLE_MUTATION_TOOLS=false
MCP_AUTH_MODE=inherit
MCP_OAUTH_INTROSPECTION_URL=
MCP_OAUTH_CLIENT_ID=
MCP_OAUTH_CLIENT_SECRET=
MCP_OAUTH_ISSUER=
MCP_OAUTH_AUDIENCE=
MCP_OAUTH_TIMEOUT_SECONDS=8
LOG_LEVEL=INFO LOG_LEVEL=INFO

@ -47,6 +47,8 @@ Edit `.env` and set:
- `STRANDS_OPENAI_API_KEY` and optional `STRANDS_MODEL_ID` / `STRANDS_OPENAI_BASE_URL` - `STRANDS_OPENAI_API_KEY` and optional `STRANDS_MODEL_ID` / `STRANDS_OPENAI_BASE_URL`
- optional unsubscribe digest settings (`UNSUBSCRIBE_*`) - optional unsubscribe digest settings (`UNSUBSCRIBE_*`)
- optional scan frequency and additional Gmail filters (`GMAIL_QUERY`) - optional scan frequency and additional Gmail filters (`GMAIL_QUERY`)
- optional MCP auth override (`MCP_AUTH_MODE=inherit|api_key|jwt|hybrid|oauth`)
- for MCP OAuth mode: `MCP_OAUTH_INTROSPECTION_URL`, optional `MCP_OAUTH_CLIENT_ID` / `MCP_OAUTH_CLIENT_SECRET`, and optional `MCP_OAUTH_ISSUER` / `MCP_OAUTH_AUDIENCE`
## 4) Run ## 4) Run
@ -157,6 +159,18 @@ To expose internal mutation tools (`scan_mailbox`, `list_unsubscribe_candidates`
MCP_ENABLE_MUTATION_TOOLS=true MCP_ENABLE_MUTATION_TOOLS=true
``` ```
MCP auth mode defaults to `inherit` (same mode as `AUTH_MODE`).
To force OAuth only on MCP:
```bash
MCP_AUTH_MODE=oauth
MCP_OAUTH_INTROSPECTION_URL=https://issuer.example/oauth2/introspect
MCP_OAUTH_CLIENT_ID=your-resource-server-client-id
MCP_OAUTH_CLIENT_SECRET=your-resource-server-client-secret
MCP_OAUTH_ISSUER=https://issuer.example
MCP_OAUTH_AUDIENCE=personal-agent-mcp
```
Scopes required per MCP tool: Scopes required per MCP tool:
- `check_availability`: `availability:read` - `check_availability`: `availability:read`

@ -45,6 +45,13 @@ class Settings:
a2a_public_base_url: str | None a2a_public_base_url: str | None
a2a_agent_name: str a2a_agent_name: str
a2a_agent_description: str a2a_agent_description: str
mcp_auth_mode: str
mcp_oauth_introspection_url: str | None
mcp_oauth_client_id: str | None
mcp_oauth_client_secret: str
mcp_oauth_issuer: str | None
mcp_oauth_audience: str | None
mcp_oauth_timeout_seconds: float
mcp_enable_mutation_tools: bool mcp_enable_mutation_tools: bool
log_level: str log_level: str
@ -106,6 +113,13 @@ def get_settings() -> Settings:
"A2A_AGENT_DESCRIPTION", "A2A_AGENT_DESCRIPTION",
"Personal productivity agent for calendar availability and email operations.", "Personal productivity agent for calendar availability and email operations.",
), ),
mcp_auth_mode=_normalize_mcp_auth_mode(os.getenv("MCP_AUTH_MODE", "inherit")),
mcp_oauth_introspection_url=os.getenv("MCP_OAUTH_INTROSPECTION_URL", "").strip() or None,
mcp_oauth_client_id=os.getenv("MCP_OAUTH_CLIENT_ID", "").strip() or None,
mcp_oauth_client_secret=os.getenv("MCP_OAUTH_CLIENT_SECRET", "").strip(),
mcp_oauth_issuer=os.getenv("MCP_OAUTH_ISSUER", "").strip() or None,
mcp_oauth_audience=os.getenv("MCP_OAUTH_AUDIENCE", "").strip() or None,
mcp_oauth_timeout_seconds=float(os.getenv("MCP_OAUTH_TIMEOUT_SECONDS", "8")),
mcp_enable_mutation_tools=_as_bool(os.getenv("MCP_ENABLE_MUTATION_TOOLS", "false")), mcp_enable_mutation_tools=_as_bool(os.getenv("MCP_ENABLE_MUTATION_TOOLS", "false")),
log_level=os.getenv("LOG_LEVEL", "INFO"), log_level=os.getenv("LOG_LEVEL", "INFO"),
) )
@ -128,3 +142,10 @@ def _normalize_auth_mode(value: str) -> str:
if normalized in {"api_key", "jwt", "hybrid"}: if normalized in {"api_key", "jwt", "hybrid"}:
return normalized return normalized
return "api_key" return "api_key"
def _normalize_mcp_auth_mode(value: str) -> str:
normalized = value.strip().lower()
if normalized in {"inherit", "api_key", "jwt", "hybrid", "oauth"}:
return normalized
return "inherit"

@ -1,18 +1,18 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import replace
import logging import logging
from typing import Any from typing import Any
from fastapi import HTTPException from fastapi import HTTPException
from mcp.server.fastmcp import Context from mcp.server.fastmcp import Context
from app.config import get_settings from app.config import Settings, get_settings
from app.core.service import CoreAgentService from app.core.service import CoreAgentService
from app.security import AuthBackend from app.security import AuthBackend
settings = get_settings() settings = get_settings()
core_service = CoreAgentService(settings=settings, logger=logging.getLogger("personal-agent.mcp")) core_service = CoreAgentService(settings=settings, logger=logging.getLogger("personal-agent.mcp"))
auth_backend = AuthBackend(settings=settings)
def check_availability( def check_availability(
@ -145,3 +145,30 @@ def _extract_auth_headers(ctx: Context | None) -> tuple[str | None, str | None]:
x_api_key = headers.get("x-api-key") x_api_key = headers.get("x-api-key")
authorization = headers.get("authorization") authorization = headers.get("authorization")
return x_api_key, authorization return x_api_key, authorization
def _build_mcp_auth_backend(base_settings: Settings) -> AuthBackend:
resolved_mode = _resolve_mcp_auth_mode(base_settings)
effective_settings = (
base_settings
if resolved_mode == base_settings.auth_mode
else replace(base_settings, auth_mode=resolved_mode)
)
return AuthBackend(
settings=effective_settings,
oauth_introspection_url=base_settings.mcp_oauth_introspection_url,
oauth_client_id=base_settings.mcp_oauth_client_id,
oauth_client_secret=base_settings.mcp_oauth_client_secret or None,
oauth_issuer=base_settings.mcp_oauth_issuer,
oauth_audience=base_settings.mcp_oauth_audience,
oauth_timeout_seconds=base_settings.mcp_oauth_timeout_seconds,
)
def _resolve_mcp_auth_mode(base_settings: Settings) -> str:
if base_settings.mcp_auth_mode == "inherit":
return base_settings.auth_mode
return base_settings.mcp_auth_mode
auth_backend = _build_mcp_auth_backend(settings)

@ -1,15 +1,26 @@
from __future__ import annotations from __future__ import annotations
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import logging
from starlette.applications import Starlette from starlette.applications import Starlette
from starlette.routing import Mount from starlette.routing import Mount
from app.config import get_settings
from app.mcp import mcp from app.mcp import mcp
settings = get_settings()
logger = logging.getLogger("personal-agent.mcp")
@asynccontextmanager @asynccontextmanager
async def lifespan(_: Starlette): async def lifespan(_: Starlette):
effective_mode = settings.auth_mode if settings.mcp_auth_mode == "inherit" else settings.mcp_auth_mode
logger.info(
"MCP authentication mode=%s (base AUTH_MODE=%s)",
effective_mode,
settings.auth_mode,
)
async with mcp.session_manager.run(): async with mcp.session_manager.run():
yield yield

@ -7,6 +7,9 @@ import hashlib
import hmac import hmac
import json import json
from typing import Any from typing import Any
from urllib import error as urllib_error
from urllib import parse as urllib_parse
from urllib import request as urllib_request
from fastapi import HTTPException, status from fastapi import HTTPException, status
@ -21,8 +24,24 @@ class AuthContext:
class AuthBackend: class AuthBackend:
def __init__(self, settings: Settings) -> None: def __init__(
self,
settings: Settings,
*,
oauth_introspection_url: str | None = None,
oauth_client_id: str | None = None,
oauth_client_secret: str | None = None,
oauth_issuer: str | None = None,
oauth_audience: str | None = None,
oauth_timeout_seconds: float = 8.0,
) -> None:
self.settings = settings self.settings = settings
self.oauth_introspection_url = oauth_introspection_url
self.oauth_client_id = oauth_client_id
self.oauth_client_secret = oauth_client_secret
self.oauth_issuer = oauth_issuer
self.oauth_audience = oauth_audience
self.oauth_timeout_seconds = oauth_timeout_seconds
def is_enabled(self) -> bool: def is_enabled(self) -> bool:
if self.settings.auth_mode == "api_key": if self.settings.auth_mode == "api_key":
@ -33,6 +52,8 @@ class AuthBackend:
return bool( return bool(
self.settings.agent_api_key.strip() or self.settings.auth_jwt_secret.strip() self.settings.agent_api_key.strip() or self.settings.auth_jwt_secret.strip()
) )
if self.settings.auth_mode == "oauth":
return bool((self.oauth_introspection_url or "").strip())
return bool(self.settings.agent_api_key.strip()) return bool(self.settings.agent_api_key.strip())
def authenticate( def authenticate(
@ -57,6 +78,11 @@ class AuthBackend:
authorization=authorization, authorization=authorization,
required_scopes=required_scopes, required_scopes=required_scopes,
) )
if mode == "oauth":
return self._authenticate_oauth(
authorization=authorization,
required_scopes=required_scopes,
)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@ -151,6 +177,48 @@ class AuthBackend:
subject = str(claims.get("sub") or "jwt-subject") subject = str(claims.get("sub") or "jwt-subject")
return AuthContext(subject=subject, auth_type="jwt", scopes=scope_values) return AuthContext(subject=subject, auth_type="jwt", scopes=scope_values)
def _authenticate_oauth(
self,
*,
authorization: str | None,
required_scopes: set[str],
) -> AuthContext:
if not self.oauth_introspection_url:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="OAuth introspection URL is required in oauth auth mode.",
)
token = self._resolve_bearer(authorization)
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing Bearer token.",
)
claims = self._introspect_oauth_token(token)
if not bool(claims.get("active")):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Inactive OAuth token.",
)
_validate_jwt_claims(
claims=claims,
expected_issuer=self.oauth_issuer,
expected_audience=self.oauth_audience,
)
scope_values = _extract_scopes(claims)
if required_scopes and not required_scopes.issubset(scope_values):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Missing required scope.",
)
subject = str(claims.get("sub") or claims.get("client_id") or "oauth-subject")
return AuthContext(subject=subject, auth_type="oauth", scopes=scope_values)
def _resolve_api_key(self, *, x_api_key: str | None, authorization: str | None) -> str | None: def _resolve_api_key(self, *, x_api_key: str | None, authorization: str | None) -> str | None:
if x_api_key: if x_api_key:
return x_api_key return x_api_key
@ -166,6 +234,55 @@ class AuthBackend:
return parts[1] return parts[1]
return None return None
def _introspect_oauth_token(self, token: str) -> dict[str, Any]:
if self.oauth_client_secret and not self.oauth_client_id:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="OAuth client_id is required when client_secret is configured.",
)
request_body: dict[str, str] = {"token": token}
if self.oauth_client_id and not self.oauth_client_secret:
request_body["client_id"] = self.oauth_client_id
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
}
if self.oauth_client_id and self.oauth_client_secret:
basic_secret = base64.b64encode(
f"{self.oauth_client_id}:{self.oauth_client_secret}".encode("utf-8")
).decode("ascii")
headers["Authorization"] = f"Basic {basic_secret}"
body_bytes = urllib_parse.urlencode(request_body).encode("utf-8")
request = urllib_request.Request(
self.oauth_introspection_url, # type: ignore[arg-type]
data=body_bytes,
headers=headers,
method="POST",
)
try:
with urllib_request.urlopen(request, timeout=self.oauth_timeout_seconds) as response:
payload = json.loads(response.read().decode("utf-8"))
except (urllib_error.HTTPError, urllib_error.URLError) as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="OAuth introspection request failed.",
) from exc
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Invalid OAuth introspection response.",
) from exc
if not isinstance(payload, dict):
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Invalid OAuth introspection response.",
)
return payload
def _decode_hs256_jwt(*, token: str, secret: str) -> dict[str, Any]: def _decode_hs256_jwt(*, token: str, secret: str) -> dict[str, Any]:
try: try:

@ -46,11 +46,24 @@ MCP_ENABLE_MUTATION_TOOLS=true
MCP tools call the shared auth backend and read auth headers from request context. MCP tools call the shared auth backend and read auth headers from request context.
MCP auth mode resolution:
- `MCP_AUTH_MODE=inherit` (default): use `AUTH_MODE`
- `MCP_AUTH_MODE=api_key|jwt|hybrid|oauth`: override only for MCP
Supported auth headers: Supported auth headers:
- `X-API-Key` - `X-API-Key`
- `Authorization: Bearer ...` - `Authorization: Bearer ...`
For `MCP_AUTH_MODE=oauth`, bearer tokens are validated via OAuth token introspection:
- `MCP_OAUTH_INTROSPECTION_URL` (required)
- `MCP_OAUTH_CLIENT_ID` / `MCP_OAUTH_CLIENT_SECRET` (optional; use for introspection endpoint auth)
- `MCP_OAUTH_ISSUER` (optional strict `iss` match)
- `MCP_OAUTH_AUDIENCE` (optional required audience value)
- `MCP_OAUTH_TIMEOUT_SECONDS` (default `8`)
Required scopes: Required scopes:
- `check_availability`: `availability:read` - `check_availability`: `availability:read`
@ -91,6 +104,7 @@ Expected output by mode:
If tools fail with auth errors: If tools fail with auth errors:
- Check `AUTH_MODE` and credentials - Check `AUTH_MODE` and credentials
- Check `MCP_AUTH_MODE` override and related `MCP_OAUTH_*` variables
- Confirm JWT contains required scopes - Confirm JWT contains required scopes
- For API key mode, verify `AGENT_API_KEY` - For API key mode, verify `AGENT_API_KEY`
@ -99,4 +113,3 @@ If tool calls fail with Google errors:
- Verify OAuth file mounts in Docker: - Verify OAuth file mounts in Docker:
- `GOOGLE_CLIENT_SECRETS_FILE` - `GOOGLE_CLIENT_SECRETS_FILE`
- `GOOGLE_TOKEN_FILE` - `GOOGLE_TOKEN_FILE`

@ -14,6 +14,11 @@ The same backend is used across:
- A2A adapter - A2A adapter
- MCP tools - MCP tools
MCP can override auth mode independently with `MCP_AUTH_MODE`:
- `inherit` (default): use `AUTH_MODE`
- `api_key|jwt|hybrid|oauth`: MCP-only override
## Recommended deployment posture ## Recommended deployment posture
External traffic: External traffic:
@ -21,6 +26,7 @@ External traffic:
- Use `AUTH_MODE=jwt` - Use `AUTH_MODE=jwt`
- Require HTTPS at reverse proxy/gateway - Require HTTPS at reverse proxy/gateway
- Restrict exposed routes to required protocol endpoints - Restrict exposed routes to required protocol endpoints
- For MCP connectors, `MCP_AUTH_MODE=oauth` is recommended
Internal traffic: Internal traffic:
@ -107,4 +113,3 @@ Rollback:
1. Redeploy previous image/tag. 1. Redeploy previous image/tag.
2. Verify health and protocol smoke checks. 2. Verify health and protocol smoke checks.
3. Keep state files (`data/*.json`) unchanged during rollback. 3. Keep state files (`data/*.json`) unchanged during rollback.

@ -160,3 +160,66 @@ def test_auth_backend_hybrid_mode_uses_jwt_when_api_key_missing() -> None:
assert context.auth_type == "jwt" assert context.auth_type == "jwt"
assert context.subject == "fallback-jwt" assert context.subject == "fallback-jwt"
def test_auth_backend_oauth_mode_validates_introspection_token(monkeypatch) -> None:
settings = _make_settings(
auth_mode="oauth",
agent_api_key="",
auth_jwt_secret="",
)
backend = AuthBackend(
settings=settings,
oauth_introspection_url="https://issuer.example/introspect",
oauth_issuer="https://issuer.example",
oauth_audience="personal-agent-mcp",
)
monkeypatch.setattr(
backend,
"_introspect_oauth_token",
lambda _: {
"active": True,
"sub": "oauth-agent",
"iss": "https://issuer.example",
"aud": "personal-agent-mcp",
"scope": "availability:read unsubscribe:read",
"exp": int(time.time()) + 3600,
},
)
context = backend.authenticate(
x_api_key=None,
authorization="Bearer oauth-token",
required_scopes={"availability:read"},
)
assert context.auth_type == "oauth"
assert context.subject == "oauth-agent"
assert "availability:read" in context.scopes
def test_auth_backend_oauth_mode_rejects_inactive_token(monkeypatch) -> None:
settings = _make_settings(
auth_mode="oauth",
agent_api_key="",
auth_jwt_secret="",
)
backend = AuthBackend(
settings=settings,
oauth_introspection_url="https://issuer.example/introspect",
)
monkeypatch.setattr(
backend,
"_introspect_oauth_token",
lambda _: {"active": False},
)
with pytest.raises(HTTPException) as exc_info:
backend.authenticate(
x_api_key=None,
authorization="Bearer oauth-token",
required_scopes={"availability:read"},
)
assert exc_info.value.status_code == 401
assert str(exc_info.value.detail) == "Inactive OAuth token."

@ -98,3 +98,40 @@ def test_mcp_scan_mailbox_requires_mail_scan_scope(monkeypatch) -> None:
ctx=_DummyCtx(headers={"x-api-key": "mcp-key"}), ctx=_DummyCtx(headers={"x-api-key": "mcp-key"}),
) )
assert payload["scanned"] == 10 assert payload["scanned"] == 10
def test_mcp_auth_mode_oauth_uses_bearer_token(monkeypatch) -> None:
mcp_settings = replace(
get_settings(),
auth_mode="api_key",
agent_api_key="api-key-not-used-for-mcp",
auth_jwt_secret="",
mcp_auth_mode="oauth",
mcp_oauth_introspection_url="https://issuer.example/introspect",
mcp_oauth_issuer="https://issuer.example",
mcp_oauth_audience="personal-agent-mcp",
)
backend = mcp_tools_module._build_mcp_auth_backend(mcp_settings)
monkeypatch.setattr(
backend,
"_introspect_oauth_token",
lambda _: {
"active": True,
"sub": "oauth-agent",
"iss": "https://issuer.example",
"aud": "personal-agent-mcp",
"scope": "availability:read",
},
)
monkeypatch.setattr(mcp_tools_module, "auth_backend", backend)
monkeypatch.setattr(mcp_tools_module, "core_service", _DummyCoreService())
payload = mcp_tools_module.check_availability(
start="2026-03-10T09:00:00+01:00",
end="2026-03-10T10:00:00+01:00",
calendar_ids=["primary"],
ctx=_DummyCtx(headers={"authorization": "Bearer oauth-token"}),
)
assert payload["available"] is True
assert payload["checked_calendars"] == ["primary"]

Loading…
Cancel
Save