From 0b9886fc56a1348e46846cf00d7201346dadcefd Mon Sep 17 00:00:00 2001 From: oabrivard Date: Tue, 10 Mar 2026 09:25:02 +0100 Subject: [PATCH] Add MCP OAuth auth mode via token introspection --- .env.example | 7 +++ README.md | 14 +++++ app/config.py | 21 +++++++ app/mcp/tools.py | 31 ++++++++- app/mcp_main.py | 11 ++++ app/security/auth.py | 119 ++++++++++++++++++++++++++++++++++- docs/mcp.md | 15 ++++- docs/security.md | 7 ++- tests/test_auth_backend.py | 63 +++++++++++++++++++ tests/test_mcp_tools_auth.py | 37 +++++++++++ 10 files changed, 320 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index efaf71b..04120b9 100644 --- a/.env.example +++ b/.env.example @@ -36,4 +36,11 @@ A2A_PUBLIC_BASE_URL= A2A_AGENT_NAME=Personal Agent A2A_AGENT_DESCRIPTION=Personal productivity agent for calendar availability and email operations. 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 diff --git a/README.md b/README.md index 75d48bf..430165f 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,8 @@ Edit `.env` and set: - `STRANDS_OPENAI_API_KEY` and optional `STRANDS_MODEL_ID` / `STRANDS_OPENAI_BASE_URL` - optional unsubscribe digest settings (`UNSUBSCRIBE_*`) - 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 @@ -157,6 +159,18 @@ To expose internal mutation tools (`scan_mailbox`, `list_unsubscribe_candidates` 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: - `check_availability`: `availability:read` diff --git a/app/config.py b/app/config.py index f7bdf82..5f1e2ab 100644 --- a/app/config.py +++ b/app/config.py @@ -45,6 +45,13 @@ class Settings: a2a_public_base_url: str | None a2a_agent_name: 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 log_level: str @@ -106,6 +113,13 @@ def get_settings() -> Settings: "A2A_AGENT_DESCRIPTION", "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")), 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"}: return normalized 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" diff --git a/app/mcp/tools.py b/app/mcp/tools.py index c701389..03446a5 100644 --- a/app/mcp/tools.py +++ b/app/mcp/tools.py @@ -1,18 +1,18 @@ from __future__ import annotations +from dataclasses import replace import logging from typing import Any from fastapi import HTTPException 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.security import AuthBackend settings = get_settings() core_service = CoreAgentService(settings=settings, logger=logging.getLogger("personal-agent.mcp")) -auth_backend = AuthBackend(settings=settings) 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") authorization = headers.get("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) diff --git a/app/mcp_main.py b/app/mcp_main.py index 2ac826f..e282c83 100644 --- a/app/mcp_main.py +++ b/app/mcp_main.py @@ -1,15 +1,26 @@ from __future__ import annotations from contextlib import asynccontextmanager +import logging from starlette.applications import Starlette from starlette.routing import Mount +from app.config import get_settings from app.mcp import mcp +settings = get_settings() +logger = logging.getLogger("personal-agent.mcp") + @asynccontextmanager 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(): yield diff --git a/app/security/auth.py b/app/security/auth.py index 6dcd0dc..66220de 100644 --- a/app/security/auth.py +++ b/app/security/auth.py @@ -7,6 +7,9 @@ import hashlib import hmac import json 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 @@ -21,8 +24,24 @@ class AuthContext: 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.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: if self.settings.auth_mode == "api_key": @@ -33,6 +52,8 @@ class AuthBackend: return bool( 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()) def authenticate( @@ -57,6 +78,11 @@ class AuthBackend: authorization=authorization, required_scopes=required_scopes, ) + if mode == "oauth": + return self._authenticate_oauth( + authorization=authorization, + required_scopes=required_scopes, + ) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -151,6 +177,48 @@ class AuthBackend: subject = str(claims.get("sub") or "jwt-subject") 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: if x_api_key: return x_api_key @@ -166,6 +234,55 @@ class AuthBackend: return parts[1] 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]: try: diff --git a/docs/mcp.md b/docs/mcp.md index e2245c9..6f03a55 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -46,11 +46,24 @@ MCP_ENABLE_MUTATION_TOOLS=true 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: - `X-API-Key` - `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: - `check_availability`: `availability:read` @@ -91,6 +104,7 @@ Expected output by mode: If tools fail with auth errors: - Check `AUTH_MODE` and credentials +- Check `MCP_AUTH_MODE` override and related `MCP_OAUTH_*` variables - Confirm JWT contains required scopes - 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: - `GOOGLE_CLIENT_SECRETS_FILE` - `GOOGLE_TOKEN_FILE` - diff --git a/docs/security.md b/docs/security.md index 010a587..d4eb1d8 100644 --- a/docs/security.md +++ b/docs/security.md @@ -14,6 +14,11 @@ The same backend is used across: - A2A adapter - 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 External traffic: @@ -21,6 +26,7 @@ External traffic: - Use `AUTH_MODE=jwt` - Require HTTPS at reverse proxy/gateway - Restrict exposed routes to required protocol endpoints +- For MCP connectors, `MCP_AUTH_MODE=oauth` is recommended Internal traffic: @@ -107,4 +113,3 @@ Rollback: 1. Redeploy previous image/tag. 2. Verify health and protocol smoke checks. 3. Keep state files (`data/*.json`) unchanged during rollback. - diff --git a/tests/test_auth_backend.py b/tests/test_auth_backend.py index e4c520e..d9f9bdb 100644 --- a/tests/test_auth_backend.py +++ b/tests/test_auth_backend.py @@ -160,3 +160,66 @@ def test_auth_backend_hybrid_mode_uses_jwt_when_api_key_missing() -> None: assert context.auth_type == "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." diff --git a/tests/test_mcp_tools_auth.py b/tests/test_mcp_tools_auth.py index 392d21e..1c35709 100644 --- a/tests/test_mcp_tools_auth.py +++ b/tests/test_mcp_tools_auth.py @@ -98,3 +98,40 @@ def test_mcp_scan_mailbox_requires_mail_scan_scope(monkeypatch) -> None: ctx=_DummyCtx(headers={"x-api-key": "mcp-key"}), ) 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"]