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_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

@ -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`

@ -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"

@ -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)

@ -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

@ -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:

@ -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`

@ -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.

@ -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."

@ -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"]

Loading…
Cancel
Save