diff --git a/AGENTS.md b/AGENTS.md index 09138fdc..eec3c28a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,8 +6,9 @@ TopicEye 是给内容创作者用的选题雷达:持续抓取 25+ 信源(RSS 播客 / newsletter / 趋势榜),用可解释的六维引擎给每条内容打分,挑出今天值得写的题目。 对外有两条 agent 入口:REST(`AGENT_API.md` 文档化的 skill/scoring 端点)与 MCP -server(`app/mcp/`,挂载在 `/mcp`,Bearer token 认证,官方 `mcp` SDK 实现 2026-07-28 -无状态协议)。 +server(`app/mcp/`,挂载在 `/mcp`,Bearer token 认证——静态个人 token 或动态 +OAuth 2.1(授权码 + PKCE + DCR,AS 端点挂站点根路径)两可,官方 `mcp` SDK 实现 +2026-07-28 无状态协议)。 两件不看代码就会踩空的事: @@ -64,7 +65,9 @@ scripts/check_layering.py`,CI 作业 `backend-layering`)只作用于 `app/ap `select/insert/update/delete` 构造与 `db.execute/db.add/db.scalars/db.scalar`。上表其余禁止项 它查不出——`from app.models import ` 与 api 层合法要用的 Enum、依赖注入同模块, 自动判定会误报,只能人工看。`app/mcp/` 是与 `api/v1` 平级的入口层,不在机器检查范围内, -按上表 api 行的纪律人工把关(当前只调 services 与 `DailyReportRepository`)。 +按上表 api 行的纪律人工把关(当前只调 services 与 `DailyReportRepository`;OAuth +provider 的业务编排在 `app/services/mcp_oauth_service.py`,同意页 REST 端点在 +`app/api/v1/mcp_oauth.py`)。 ### 2.3 迁移与高风险变更 diff --git a/AGENT_API.md b/AGENT_API.md index 823ad7bf..32734fa1 100644 --- a/AGENT_API.md +++ b/AGENT_API.md @@ -164,7 +164,10 @@ The full machine-readable spec is available at `/openapi.json` (or browse intera TopicEye also speaks the **Model Context Protocol** natively: a streamable-http MCP server is mounted at **`/mcp`** (2026-07-28 stateless protocol; the same server auto-negotiates older protocol revisions for legacy clients). -**Auth is the same Bearer token as the REST API** (see Authentication above) — create a personal API token, then configure your MCP client to send it as an `Authorization: Bearer ` header. +**Two auth paths are supported:** + +1. **Dynamic OAuth 2.1 (browser pop-up login)** — the standard MCP client flow. Point your MCP client at `https:///mcp` with no token; the client discovers the authorization server via RFC 9728 metadata, registers itself (RFC 7591 dynamic client registration), and opens a browser for you to log in and approve. Access tokens live 60 min, refresh tokens 30 days with rotation (a replayed refresh token revokes its whole family). +2. **Static Bearer token** (see Authentication above) — create a personal API token and configure your MCP client to send it as an `Authorization: Bearer ` header. Works with every MCP client, including ones without OAuth support. ### Tools (no destructive writes) @@ -179,7 +182,7 @@ All tools mirror the REST agent endpoints. Note `get_daily_report` without `date ### Client config examples -ZCode / Claude Code style (header-authenticated streamable-http): +ZCode / Claude Code style (header-authenticated streamable-http, static token): ```json { @@ -192,9 +195,28 @@ ZCode / Claude Code style (header-authenticated streamable-http): } ``` +OAuth mode needs no `headers` — just the URL. The client will pop up a browser on first connect; after you log in and click 授权 (approve) on the consent page, it stores the tokens itself. + +### Dynamic OAuth endpoints + +The authorization server (AS) lives at the site root, with `issuer = SITE_BASE_URL`: + +| Endpoint | Purpose | +|---|---| +| `GET /.well-known/oauth-authorization-server` | RFC 8414 AS metadata | +| `GET /.well-known/oauth-protected-resource/mcp` | RFC 9728 resource metadata (points clients at the AS) | +| `GET/POST /authorize` | Authorization endpoint (PKCE S256 mandatory) → redirects to the consent page. Requires a logged-in browser session: anonymous GET is redirected to the login page with a `returnTo` bounce-back; anonymous POST gets 400. The initiating user is bound to the request — only the initiator can view/approve/deny it on the consent page | +| `POST /token` | Token endpoint (authorization_code + refresh_token grants). A replayed (already-consumed) authorization code is rejected and logged as an exfiltration signal | +| `POST /register` | RFC 7591 dynamic client registration | +| `POST /revoke` | RFC 7009 revocation (family-wide; note: the SDK requires a `client_secret` form field to be present — public clients send an empty value) | + +The consent page itself (`/mcp/authorize` on the frontend) talks to three session-authenticated REST endpoints: `GET /api/v1/mcp-oauth/authorize-info`, `POST /api/v1/mcp-oauth/approve`, `POST /api/v1/mcp-oauth/deny` — all three initiator-only. Authorization codes are single-use and expire in 10 minutes. The consent page marks every DCR-registered client as an unvetted third-party app (client names are self-declared). + +Tool-level scopes are enforced by a central fail-closed gate (`TOOL_REQUIRED_SCOPES` in `app/mcp/scopes.py`); all four current tools are read-only and require no scopes. Adding any new tool (especially the first write tool) must register it there — a CI assertion keeps the tool set and the gate registry in lockstep, and the full scope model is the tracked precondition for the first write tool (issue #118). + Operational notes: -- `MCP_ENABLED=false` disables the server (no route mounted; startup logs state it explicitly). -- Rate limit: 60 req/min per IP on `/mcp`. -- Production requires `SITE_BASE_URL` to be set (enables Host/Origin validation against DNS-rebinding; otherwise a startup warning is logged and the check stays off). -- Status of dynamic OAuth authorization (browser pop-up login): not yet implemented — the advertised issuer points back at the site, and clients should use pre-provisioned API tokens. Static Bearer tokens work with every MCP client; adding a real authorization server is a follow-up option (the verifier is swappable behind the SDK's `TokenVerifier`). +- `MCP_ENABLED=false` disables the whole MCP surface (no route mounted; startup logs state it explicitly). `MCP_OAUTH_ENABLED=false` disables only the OAuth endpoints (static tokens keep working). +- Rate limits: 60 req/min per IP on `/mcp`; 30/min on `/authorize` and `/token`; 10/min on `/register`. +- Production requires `SITE_BASE_URL` to be set (enables Host/Origin validation against DNS-rebinding; otherwise a startup warning is logged and the check stays off). The OAuth issuer must be HTTPS (loopback hosts excepted) — with an invalid issuer the AS routes are not mounted, an explicit error is logged, and static tokens keep working. +- Single-origin deployments behind the Next.js proxy: `/authorize`, `/token`, `/register`, `/revoke`, and `/.well-known/*` are proxied to the backend by `next.config.js` rewrites. diff --git a/backend/alembic/env.py b/backend/alembic/env.py index dafeee3c..58cc016b 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -37,6 +37,7 @@ import app.models.favorite # noqa: F401, E402 import app.models.feedback # noqa: F401, E402 import app.models.llm_model # noqa: F401, E402 +import app.models.mcp_oauth # noqa: F401, E402 import app.models.model_catalog # noqa: F401, E402 import app.models.monthly_digest # noqa: F401, E402 import app.models.mother_topic # noqa: F401, E402 diff --git a/backend/alembic/versions/20261017_1030_c7a4e9f21b86_add_mcp_oauth_tables.py b/backend/alembic/versions/20261017_1030_c7a4e9f21b86_add_mcp_oauth_tables.py new file mode 100644 index 00000000..38b61840 --- /dev/null +++ b/backend/alembic/versions/20261017_1030_c7a4e9f21b86_add_mcp_oauth_tables.py @@ -0,0 +1,119 @@ +"""MCP 动态 OAuth 授权服务器存储:三张表 + +Revision ID: c7a4e9f21b86 +Revises: b4e2c8a01f73 +Create Date: 2026-10-17 + +#116 起 MCP server 支持标准 OAuth 2.1 浏览器弹窗登录(授权码 + PKCE + +DCR),需要授权服务器侧的持久化: + +- mcp_oauth_clients:RFC 7591 动态注册的客户端(secret 为 Fernet 密文, + 同 oauth_providers 先例,依赖 INTEGRATION_SECRET_KEY 已配置) +- mcp_oauth_authorizations:/authorize 落的 pending 授权请求,用户批准后 + 同一行盖 code_hash 变一次性授权码(pending/approved/denied/consumed) +- mcp_oauth_tokens:access / refresh 共表,库存 sha256(raw 只回传一次), + family_id 串起轮换链供重放检测整族撤销 + +新表全部为增量(无数据回填、不改既有表),downgrade 直接删表;若生产 +已有用户完成过 OAuth 授权,回滚前先备份 token 行。 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "c7a4e9f21b86" +down_revision: str | None = "b4e2c8a01f73" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "mcp_oauth_clients", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("client_id", sa.String(length=64), nullable=False, comment="SDK 注册时生成的 uuid4"), + sa.Column("client_name", sa.String(length=200), nullable=True, comment="同意页展示的应用名"), + sa.Column( + "client_secret_enc", + sa.Text(), + nullable=True, + comment="Fernet 密文(enc:v1:);公共客户端(method=none)为 NULL", + ), + sa.Column( + "metadata_json", + sa.Text(), + nullable=False, + comment="OAuthClientInformationFull dump(不含 secret)", + ), + sa.Column("registered_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=True), + ) + op.create_index("ix_mcp_oauth_clients_client_id", "mcp_oauth_clients", ["client_id"], unique=True) + + op.create_table( + "mcp_oauth_authorizations", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("request_id", sa.String(length=36), nullable=False, comment="uuid4,发给前端同意页"), + sa.Column( + "status", + sa.String(length=12), + nullable=False, + server_default="pending", + comment="pending / approved / denied / consumed", + ), + sa.Column("client_id", sa.String(length=64), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column( + "initiator_user_id", + sa.Integer(), + nullable=True, + comment="发起授权请求的用户(绑定校验)", + ), + sa.Column("code_hash", sa.String(length=64), nullable=True, comment="批准时生成的授权码 sha256"), + sa.Column("scopes", sa.Text(), nullable=True, comment="JSON 数组"), + sa.Column("state", sa.Text(), nullable=True, comment="客户端 state 原样带回"), + sa.Column("code_challenge", sa.String(length=128), nullable=False, comment="PKCE S256 challenge"), + sa.Column("redirect_uri", sa.String(length=512), nullable=False), + sa.Column("redirect_uri_provided_explicitly", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("resource", sa.String(length=512), nullable=True, comment="RFC 8707 资源指示符"), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["initiator_user_id"], ["users.id"], ondelete="CASCADE"), + ) + op.create_index("ix_mcp_oauth_authorizations_request_id", "mcp_oauth_authorizations", ["request_id"], unique=True) + op.create_index("ix_mcp_oauth_authorizations_code_hash", "mcp_oauth_authorizations", ["code_hash"], unique=True) + op.create_index("ix_mcp_oauth_authorizations_client_status", "mcp_oauth_authorizations", ["client_id", "status"]) + + op.create_table( + "mcp_oauth_tokens", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("token_hash", sa.String(length=64), nullable=False, comment="sha256 hex"), + sa.Column("token_type", sa.String(length=10), nullable=False, comment="access / refresh"), + sa.Column("client_id", sa.String(length=64), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("family_id", sa.String(length=36), nullable=False, comment="授权码发放起的轮换链标识"), + sa.Column("scopes", sa.Text(), nullable=True, comment="JSON 数组"), + sa.Column("resource", sa.String(length=512), nullable=True, comment="RFC 8707 资源指示符"), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + ) + op.create_index("ix_mcp_oauth_tokens_token_hash", "mcp_oauth_tokens", ["token_hash"], unique=True) + op.create_index("ix_mcp_oauth_tokens_family", "mcp_oauth_tokens", ["family_id"]) + op.create_index("ix_mcp_oauth_tokens_client_type", "mcp_oauth_tokens", ["client_id", "token_type"]) + + +def downgrade() -> None: + # 回滚丢弃 OAuth 客户端注册与 token(存量静态 token 不受影响); + # 需要保留授权历史时先 pg_dump 这三张表 + op.drop_table("mcp_oauth_tokens") + op.drop_table("mcp_oauth_authorizations") + op.drop_table("mcp_oauth_clients") diff --git a/backend/app/api/v1/mcp_oauth.py b/backend/app/api/v1/mcp_oauth.py new file mode 100644 index 00000000..e06ebf3f --- /dev/null +++ b/backend/app/api/v1/mcp_oauth.py @@ -0,0 +1,84 @@ +"""MCP OAuth 同意页交互端点(cookie 会话认证)。 + +浏览器弹窗登录流程中,前端 ``/mcp/authorize`` 同意页与后端的交互面: +- GET /mcp-oauth/authorize-info — 展示待授权客户端信息(client_name 等) +- POST /mcp-oauth/approve — 用户批准,返回带 code 的客户端回调地址 +- POST /mcp-oauth/deny — 用户拒绝,返回 error=access_denied 回调地址 + +授权码签发、TTL、单次性语义在 ``app/services/mcp_oauth_service.py``; +本层只做会话校验(get_current_user)与 response shaping。三个端点都 +要求操作者是授权请求的**发起者**(service 层校验 initiator_user_id, +非发起者一律 404 不暴露存在性)——发起者绑定由 ``app/mcp/authorize_route.py`` +的 /authorize 包装路由落库,request_id 是 uuid4(≈122 bit 熵)、 +10 分钟过期且一次性。 +""" + +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.v1.auth import get_current_user +from app.core.database import get_db +from app.models.user import User +from app.services import mcp_oauth_service + +router = APIRouter( + prefix="/mcp-oauth", + tags=["mcp-oauth"], + dependencies=[Depends(get_current_user)], +) +logger = logging.getLogger(__name__) + +_GONE = "授权请求不存在、已过期或已被处理" + + +class ConsentDecisionRequest(BaseModel): + request_id: str = Field(..., min_length=1, max_length=36, description="/authorize 下发的待授权请求 id") + + +@router.get("/authorize-info") +async def get_authorize_info( + request_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """同意页展示信息:应用名、主页、请求的 scope 与回调目标主机(仅发起者)。""" + info = await mcp_oauth_service.get_authorization_info(db, request_id, viewer_user_id=current_user.id) + if info is None: + raise HTTPException(status_code=404, detail=_GONE) + return info + + +@router.post("/approve") +async def approve_authorization( + req: ConsentDecisionRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """批准授权:绑定当前用户并签发一次性授权码,返回客户端回调地址。""" + result = await mcp_oauth_service.approve_authorization(db, req.request_id, current_user.id) + if result is None: + raise HTTPException(status_code=404, detail=_GONE) + _code, redirect_to = result + await db.commit() + logger.info("MCP OAuth approved: user_id=%d client=%s", current_user.id, req.request_id) + # redirect_to 已含一次性 code;前端 window.location 跳转即可 + return {"redirect_to": redirect_to} + + +@router.post("/deny") +async def deny_authorization( + req: ConsentDecisionRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """拒绝授权:按 RFC 6749 以 error=access_denied 回调客户端(仅发起者)。""" + redirect_to = await mcp_oauth_service.deny_authorization(db, req.request_id, acting_user_id=current_user.id) + if redirect_to is None: + raise HTTPException(status_code=404, detail=_GONE) + await db.commit() + return {"redirect_to": redirect_to} diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index f3b6c7b7..c678b545 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -19,6 +19,7 @@ from app.api.v1.integrations import router as integrations_router from app.api.v1.llm_evaluations import router as llm_evaluations_router from app.api.v1.llm_models import router as llm_models_router +from app.api.v1.mcp_oauth import router as mcp_oauth_router from app.api.v1.metrics import router as metrics_router from app.api.v1.model_catalog import router as model_catalog_router from app.api.v1.monthly_digests import router as monthly_digests_router @@ -50,6 +51,7 @@ router.include_router(auth_router) router.include_router(oauth_router) router.include_router(user_api_tokens_router) +router.include_router(mcp_oauth_router) router.include_router(plans_router) router.include_router(integrations_router) router.include_router(scoring_router) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index b60ed6d3..12b912a7 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -54,6 +54,18 @@ class Settings(BaseSettings): # (个人 API token / session token,与 REST API 同一套)。关闭时不挂载 # 路由、不启动 session manager(启动日志显式记录,不静默跳过)。 MCP_ENABLED: bool = True + # MCP 动态 OAuth 授权服务器(/authorize /token /register /revoke + RFC 8414/9728 + # 元数据,挂在站点根路径)。issuer = SITE_BASE_URL;非法 issuer(如局域网 + # http,SDK 强制 https、loopback 除外)时启动显式报错并跳过挂载,静态 + # token 用法不受影响。 + MCP_OAUTH_ENABLED: bool = True + # 授权码 / pending 授权请求 TTL(秒) + MCP_OAUTH_CODE_TTL_SECONDS: int = 600 + # access token TTL(秒);refresh 轮换时旧对整体撤销 + MCP_OAUTH_ACCESS_TOKEN_TTL_SECONDS: int = 3600 + MCP_OAUTH_REFRESH_TOKEN_DAYS: int = 30 + # 同意页在前端的路径(与 SITE_BASE_URL 拼出完整回调地址) + MCP_OAUTH_CONSENT_PATH: str = "/mcp/authorize" READ_CACHE_TTL_SECONDS: float = 60.0 # 整体同步超时(秒)。包含 fetch + classify + persist 三阶段。 # 120s 对 arXiv/HN/36氪 等条目多的信源太短(classify 阶段 LLM 调用慢), diff --git a/backend/app/main.py b/backend/app/main.py index 1c1bb384..b42391a5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -23,6 +23,7 @@ import app.models.favorite # noqa: F401 import app.models.feedback # noqa: F401 import app.models.llm_model # noqa: F401 +import app.models.mcp_oauth # noqa: F401 import app.models.metrics_snapshot # noqa: F401 import app.models.monthly_digest # noqa: F401 import app.models.mother_topic # noqa: F401 @@ -561,6 +562,23 @@ async def _seed_model_catalog() -> None: app.mount("/mcp", build_streamable_http_app()) + # MCP 动态 OAuth 授权服务器(AS):/authorize /token /register /revoke + + # RFC 8414/9728 元数据,固定根路径路由(issuer 必须=SITE_BASE_URL origin)。 + # issuer 非法(SDK 强制 https、loopback 除外,如局域网 http 开发)时 + # 显式报错并跳过挂载——静态 token 用法不受影响,但不静默吞掉。 + if settings.MCP_OAUTH_ENABLED: + from app.mcp.server import build_oauth_routes # noqa: E402 + + try: + app.router.routes.extend(build_oauth_routes()) + except ValueError as exc: + logger.error( + "MCP OAuth AS 未挂载:issuer 校验失败(%s,SITE_BASE_URL=%r)。" + "/mcp 仍可用静态 Bearer token,但动态 OAuth 登录不可用", + exc, + settings.SITE_BASE_URL, + ) + # ── 根路径 /metrics 别名(Prometheus 标准约定)───────────────────────── # prometheus.yml 默认 metrics_path: /metrics,此处提供根路径别名 diff --git a/backend/app/mcp/auth.py b/backend/app/mcp/auth.py index 2668e937..4a650594 100644 --- a/backend/app/mcp/auth.py +++ b/backend/app/mcp/auth.py @@ -1,10 +1,17 @@ """MCP bearer token 校验 — 桥接到站点现有认证体系。 -实现官方 SDK 的 ``TokenVerifier`` 协议,把 MCP 请求的 Bearer token 交给 -``auth_service.get_user_for_token`` 验证,因此接受与 REST API 完全相同的 -两种 token:个人 API token(``POST /api/v1/me/api-tokens`` 自助创建)和 -浏览器 session token。验证通过后映射为 ``AccessToken``,``client_id`` -携带 ``user.id``,工具层据此取调用者身份(如按用户读自己的日报)。 +实现官方 SDK 的 ``TokenVerifier`` 协议,按序尝试两种口径: + +1. **动态 OAuth access token**(浏览器弹窗登录流程签发,见 + ``app/mcp/oauth_provider.py``):按 sha256 查 ``mcp_oauth_tokens``, + 校验未撤销/未过期;``client_id`` 填 ``str(user.id)``(工具层 + ``_caller_user_id`` 的约定),OAuth 客户端身份在 ``claims``。 +2. **静态 token**:``auth_service.get_user_for_token`` 验证,接受与 + REST API 完全相同的两种 token——个人 API token + (``POST /api/v1/me/api-tokens`` 自助创建)和浏览器 session token。 + +验证通过后映射为 ``AccessToken``,``client_id`` 携带 ``user.id``,工具层 +据此取调用者身份(如按用户读自己的日报)。 """ from __future__ import annotations @@ -14,15 +21,27 @@ from mcp.server.auth.provider import AccessToken, TokenVerifier from app.core.database import async_session +from app.services import mcp_oauth_service from app.services.auth_service import get_user_for_token logger = logging.getLogger(__name__) class TopicEyeTokenVerifier(TokenVerifier): - """Verify MCP bearer tokens against TopicEye's session/API-token store.""" + """Verify MCP bearer tokens against TopicEye's OAuth/static-token stores.""" async def verify_token(self, token: str) -> AccessToken | None: + # 1) 动态 OAuth access token(mcp_oauth_tokens 表,hash 索引直查) + try: + async with async_session() as db: + access = await mcp_oauth_service.resolve_access_token(db, token) + if access is not None: + return access + except Exception: + # OAuth 表查不到不阻断静态 token 路径;真异常(库故障)由下方兜底统一记日志 + logger.debug("MCP OAuth access token 解析未命中或失败,回落静态 token", exc_info=True) + + # 2) 静态 token(session / 个人 API token) try: async with async_session() as db: user = await get_user_for_token(db, token) diff --git a/backend/app/mcp/authorize_route.py b/backend/app/mcp/authorize_route.py new file mode 100644 index 00000000..32fe240b --- /dev/null +++ b/backend/app/mcp/authorize_route.py @@ -0,0 +1,107 @@ +"""MCP OAuth /authorize 包装路由 — 把发起者绑定到授权请求。 + +SDK 的 ``AuthorizationHandler.handle`` → ``provider.authorize(client, params)`` +链路拿不到 HTTP 请求,无法在读 cookie 的同时落库;本模块用**同路径抢先 +注册**的包装路由补上这一环: + +1. 从 cookie(``topiceye_auth``)或 Authorization 头解析当前会话用户 + (与 REST 完全同一套 token:session / 个人 API token); +2. 未登录:GET 302 到前端登录页并带 ``returnTo`` 回跳(授权本就要求 + 资源所有者在场,RFC 6749 §4.1;POST 无法断点续传,直接 400); +3. 已登录:把 user.id 放进 ContextVar 后委托 SDK AuthorizationHandler, + provider 侧 ``authorize()`` 读 ContextVar 存入授权行;此后 + info/approve/deny 只认发起者本人(见 mcp_oauth_service._pending_row)。 + +这条绑定堵住「攻击者匿名发起 /authorize、诱导已登录受害者点同意页」 +的 approve-any 路径:request_id 天然在发起者手里,不可猜/TTL/一次性 +都不构成缓解(issue #116 圆桌裁定,Round 1 共识)。 +""" + +from __future__ import annotations + +import logging +from contextvars import ContextVar +from urllib.parse import quote + +from mcp.server.auth.handlers.authorize import AuthorizationHandler +from mcp.server.auth.provider import OAuthAuthorizationServerProvider +from mcp.server.transport_security import DEFAULT_MAX_REQUEST_BODY_SIZE, RequestBodyLimitMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse, RedirectResponse, Response +from starlette.routing import Route, request_response + +from app.core.config import settings +from app.core.database import async_session +from app.services.auth_service import get_user_for_token + +logger = logging.getLogger(__name__) + +#: 发起 /authorize 的会话用户 id;包装路由写入、provider.authorize 读取。 +#: ContextVar 在同一请求任务内传播(endpoint → SDK handler → provider)。 +initiator_user_id_var: ContextVar[int | None] = ContextVar("mcp_oauth_initiator_user_id", default=None) + + +def _site_base() -> str: + return (settings.SITE_BASE_URL or "http://127.0.0.1:8000").rstrip("/") + + +def _extract_session_token(request: Request) -> str | None: + """与 app.api.v1.auth 同一口径:Bearer 头优先,回落 auth cookie。""" + auth_header = request.headers.get("authorization", "") + if auth_header.lower().startswith("bearer "): + token = auth_header[7:].strip() + if token: + return token + return request.cookies.get(settings.AUTH_COOKIE_NAME) + + +async def _resolve_initiator(request: Request) -> int | None: + token = _extract_session_token(request) + if not token: + return None + try: + async with async_session() as db: + user = await get_user_for_token(db, token) + if user is None: + return None + user_id = user.id + # get_user_for_token 的滑动续期等副作用显式落库(同 verifier 口径) + await db.commit() + except Exception: + logger.exception("MCP OAuth /authorize 发起者解析失败,按未登录处理") + return None + return user_id + + +async def _authorize_endpoint(request: Request) -> Response: + initiator = await _resolve_initiator(request) + if initiator is None: + if request.method == "GET": + # 断点续传:登录后回到 /authorize 继续绑定与授权(returnTo 已含完整查询串) + target = f"{_site_base()}{request.url.path}" + if request.url.query: + target = f"{target}?{request.url.query}" + return RedirectResponse( + url=f"{_site_base()}/login?returnTo={quote(target, safe='')}", + status_code=302, + headers={"Cache-Control": "no-store"}, + ) + return JSONResponse({"detail": "MCP 授权需要登录会话,请通过浏览器发起"}, status_code=400) + + initiator_user_id_var.set(initiator) + provider: OAuthAuthorizationServerProvider = request.scope["mcp_oauth_provider"] + return await AuthorizationHandler(provider).handle(request) + + +def make_authorize_route(provider: OAuthAuthorizationServerProvider) -> Route: + """构建替代 SDK 原生 /authorize 的包装路由(同路径、同方法面)。""" + + async def endpoint(request: Request) -> Response: + request.scope["mcp_oauth_provider"] = provider + return await _authorize_endpoint(request) + + return Route( + "/authorize", + endpoint=RequestBodyLimitMiddleware(request_response(endpoint), DEFAULT_MAX_REQUEST_BODY_SIZE), + methods=["GET", "POST"], + ) diff --git a/backend/app/mcp/oauth_provider.py b/backend/app/mcp/oauth_provider.py new file mode 100644 index 00000000..58b81213 --- /dev/null +++ b/backend/app/mcp/oauth_provider.py @@ -0,0 +1,102 @@ +"""MCP OAuth 授权服务器 — SDK 协议适配层。 + +实现 ``OAuthAuthorizationServerProvider`` 协议(mcp 2.3.0),全部业务 +语义在 ``app/services/mcp_oauth_service.py``;本层与 ``auth.py`` 的 +``TopicEyeTokenVerifier`` 同一角色:协议适配 + 每次调用独立 session +(成功 commit、异常 rollback),不携带业务逻辑。 + +协议方法与 SDK 的分工(SDK ``TokenHandler``/``AuthorizationHandler`` 已做: +PKCE S256 比对、code/refresh 过期与 client 归属、redirect_uri 一致性): +- authorize:返回同意页 URL(302 目标); +- exchange_authorization_code:消费 code(单次性)+ 签发 token 对; +- exchange_refresh_token:轮换(旧的原子撤销,重放连坐整族); +- revoke_token:family 级联撤销(幂等)。 +""" + +from __future__ import annotations + +import logging +from contextlib import asynccontextmanager + +from mcp.server.auth.provider import ( + AccessToken, + AuthorizationCode, + AuthorizationParams, + OAuthAuthorizationServerProvider, + RefreshToken, +) +from mcp.shared.auth import OAuthClientInformationFull, OAuthToken + +from app.core.database import async_session +from app.services import mcp_oauth_service + +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def _db_session(): + """每次协议调用一个独立 session;成功 commit、异常 rollback(对齐 get_db)。""" + async with async_session() as db: + try: + yield db + await db.commit() + except Exception: + await db.rollback() + raise + + +class TopicEyeOAuthProvider(OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]): + """桥接 SDK 授权服务器协议与 TopicEye 存储;无状态,随请求建 session。""" + + async def get_client(self, client_id: str) -> OAuthClientInformationFull | None: + async with _db_session() as db: + return await mcp_oauth_service.get_client(db, client_id) + + async def register_client(self, client_info: OAuthClientInformationFull) -> None: + async with _db_session() as db: + await mcp_oauth_service.register_client(db, client_info) + + async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str: + # 发起者由宿主包装路由解析并经 ContextVar 传入(见 authorize_route.py); + # SDK 的 authorize 链路拿不到 HTTP 请求,这是唯一的传递通道 + from app.mcp.authorize_route import initiator_user_id_var + + initiator = initiator_user_id_var.get() + async with _db_session() as db: + return await mcp_oauth_service.create_authorization_request( + db, client_id=client.client_id, params=params, initiator_user_id=initiator + ) + + async def load_authorization_code( + self, client: OAuthClientInformationFull, authorization_code: str + ) -> AuthorizationCode | None: + async with _db_session() as db: + return await mcp_oauth_service.load_authorization_code( + db, client_id=client.client_id, code=authorization_code + ) + + async def exchange_authorization_code( + self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode + ) -> OAuthToken: + async with _db_session() as db: + return await mcp_oauth_service.exchange_authorization_code(db, authorization_code) + + async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None: + async with _db_session() as db: + return await mcp_oauth_service.load_refresh_token(db, client_id=client.client_id, token=refresh_token) + + async def exchange_refresh_token( + self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str] + ) -> OAuthToken: + async with _db_session() as db: + return await mcp_oauth_service.exchange_refresh_token(db, refresh_token, scopes) + + async def load_access_token(self, token: str) -> AccessToken | None: + # /revoke 专用口径:client_id 必须是 OAuth 客户端 id(SDK 归属校验), + # 不能复用 verifier 的 client_id=user.id 映射 + async with _db_session() as db: + return await mcp_oauth_service.load_access_token(db, token) + + async def revoke_token(self, token: AccessToken | RefreshToken) -> None: + async with _db_session() as db: + await mcp_oauth_service.revoke_token_by_raw(db, token.token) diff --git a/backend/app/mcp/scopes.py b/backend/app/mcp/scopes.py new file mode 100644 index 00000000..0d94729f --- /dev/null +++ b/backend/app/mcp/scopes.py @@ -0,0 +1,39 @@ +"""MCP 工具 scope 中央闸门 — 缺省拒绝(fail-closed)。 + +单一事实源:``TOOL_REQUIRED_SCOPES`` 登记每个 MCP 工具所需的 scope 集。 +调用点在 ``app/mcp/server.py`` 每个工具入口(``_enforce_tool_scopes``), +``require_tool_access`` 是唯一的判定函数: + +- 工具**未登记** → 直接拒绝(新工具不声明 scope 就上不了线); +- 已登记 → required ⊆ granted 才放行。 + +当前四个工具全只读,required 均为空集(存量静态 token 与 OAuth token +的 scopes 都是 [],行为不变)。首个**写操作**工具立项时必须在此登记 +真实 scope 语义——CI 断言 ``test_every_tool_registered_in_scope_gate`` +保证「工具集与登记集一致」,漏登记过不了门(issue #118 前置 DoD)。 +""" + +from __future__ import annotations + +from collections.abc import Iterable + +from mcp.server.mcpserver.exceptions import ToolError + +#: 工具名 → 所需 scope 集。默认全部空集(只读,无 scope 要求); +#: 「默认空」即圆桌裁定的闸门初始形态:结构就位、语义待写工具填入。 +TOOL_REQUIRED_SCOPES: dict[str, frozenset[str]] = { + "get_today_picks": frozenset(), + "get_daily_report": frozenset(), + "get_trends": frozenset(), + "score_items": frozenset(), +} + + +def require_tool_access(tool_name: str, granted_scopes: Iterable[str]) -> None: + """缺省拒绝的工具级 scope 判定;不满足抛 ToolError(客户端可见)。""" + required = TOOL_REQUIRED_SCOPES.get(tool_name) + if required is None: + raise ToolError(f"Tool {tool_name!r} is not registered in the MCP scope gate") + missing = required - set(granted_scopes) + if missing: + raise ToolError(f"Missing required scope(s): {' '.join(sorted(missing))}") diff --git a/backend/app/mcp/server.py b/backend/app/mcp/server.py index b1333596..4e0dd5b0 100644 --- a/backend/app/mcp/server.py +++ b/backend/app/mcp/server.py @@ -13,6 +13,11 @@ JSON、无服务端会话,与 2026-07-28 无状态协议形态一致,也绕开了宿主 BaseHTTPMiddleware 对 SSE 长流的潜在缓冲问题。协议版本由 SDK 与客户端 自动协商(2026-07-28 及全部旧修订均可服务)。 + +动态 OAuth 授权(浏览器弹窗登录)的 AS 路由由 ``build_oauth_routes()`` +单独构建、挂宿主根路径(issuer 必须=站点 origin);同意页交互走 +``app/api/v1/mcp_oauth.py`` 三个 REST 端点,token 业务在 +``app/services/mcp_oauth_service.py``。 """ from __future__ import annotations @@ -24,14 +29,20 @@ from mcp.server import MCPServer from mcp.server.auth.middleware.auth_context import get_access_token -from mcp.server.auth.settings import AuthSettings +from mcp.server.auth.provider import OAuthAuthorizationServerProvider +from mcp.server.auth.routes import create_auth_routes, create_protected_resource_routes +from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions, RevocationOptions from mcp.server.mcpserver.exceptions import ToolError from mcp.server.transport_security import TransportSecuritySettings -from pydantic import Field +from pydantic import AnyHttpUrl, Field +from starlette.routing import Route from app.core.config import settings from app.core.database import async_session from app.mcp.auth import TopicEyeTokenVerifier +from app.mcp.authorize_route import make_authorize_route +from app.mcp.oauth_provider import TopicEyeOAuthProvider +from app.mcp.scopes import require_tool_access from app.repositories.daily_report_repo import DailyReportRepository from app.schemas.daily_report import DailyReportResponse from app.schemas.scoring import ScoringRequestItem @@ -92,7 +103,13 @@ def _transport_security() -> TransportSecuritySettings: auth=AuthSettings( issuer_url=_base, resource_server_url=f"{_base}/mcp", + # 有意为之:工具级 scope 由 app/mcp/scopes.py 中央闸门强制(fail-closed), + # 此处 required_scopes 保持空;首个写操作工具落地真实 scope 语义时 + # 一并收口(issue #118 前置 DoD)。 required_scopes=[], + # 有意为之:开启 RFC 8707 校验会拒绝无 resource 指示符的存量静态 + # token(session / 个人 API token)。完整 scope 模型落地时走双路径 + # 收口:新 OAuth 令牌强制校验、存量静态令牌标记位豁免(issue #118)。 validate_token_resource=False, ), subscriptions=False, @@ -114,6 +131,40 @@ def build_streamable_http_app(): ) +def build_oauth_routes() -> list[Route]: + """动态 OAuth 授权服务器(AS)+ RFC 9728 protected-resource 元数据路由。 + + 返回的是固定根路径的 Starlette Route(/token /register /revoke 与两个 + /.well-known),必须挂在宿主 app 根上——issuer 又必须等于该 origin, + 因此不能放进 /mcp 子应用(会错位到 /mcp/authorize)。issuer 非法 + (非 https 且非 loopback)时抛 ValueError,由宿主决定显式降级 + (见 main.py:error log + 跳过挂载)。 + + /authorize 用自建包装路由**替换** SDK 原生路由:解析发起者会话、 + 未登录先跳登录页(returnTo 断点续传),再委托 SDK handler(见 + authorize_route.py)——绑定发起者是堵住 approve-any 冒用路径的前提。 + + DCR(RFC 7591)与 /revoke(RFC 7009)均启用:MCP 桌面客户端依赖 + 动态注册接入,token 撤销按 family 级联(见 mcp_oauth_service)。 + """ + provider: OAuthAuthorizationServerProvider = TopicEyeOAuthProvider() + sdk_routes = create_auth_routes( + provider, + issuer_url=AnyHttpUrl(_base), + client_registration_options=ClientRegistrationOptions(enabled=True), + revocation_options=RevocationOptions(enabled=True), + ) + # 同路径替换:SDK 的 /authorize 让位给带发起者绑定的包装路由 + routes = [r for r in sdk_routes if r.path != "/authorize"] + routes.append(make_authorize_route(provider)) + routes += create_protected_resource_routes( + resource_url=AnyHttpUrl(f"{_base}/mcp"), + authorization_servers=[AnyHttpUrl(_base)], + resource_name="TopicEye 选题雷达", + ) + return routes + + @asynccontextmanager async def _db_session(): """每次工具调用一个独立 session;成功 commit、异常 rollback(对齐 get_db)。""" @@ -138,6 +189,12 @@ def _caller_user_id() -> int: return int(client_id) +def _enforce_tool_scopes(tool_name: str) -> None: + """工具级 scope 中央闸门(fail-closed,见 app/mcp/scopes.py)。""" + access_token = get_access_token() + require_tool_access(tool_name, access_token.scopes if access_token else []) + + @mcp_server.tool( name="get_today_picks", title="今日精选选题", @@ -152,6 +209,7 @@ async def get_today_picks( category: str | None = None, ) -> dict[str, Any]: _caller_user_id() + _enforce_tool_scopes("get_today_picks") async with _db_session() as db: payload = await build_today_picks(db, category=category, hours=hours, limit=limit) return SkillTodayPicksResponse(**payload).model_dump(mode="json") @@ -169,6 +227,7 @@ async def get_daily_report( date: Annotated[str | None, Field(description="YYYY-MM-DD,缺省=今天")] = None, ) -> dict[str, Any]: user_id = _caller_user_id() + _enforce_tool_scopes("get_daily_report") async with _db_session() as db: if date: report = await DailyReportRepository(db).get_by_date(date, owner_user_id=user_id) @@ -192,6 +251,7 @@ async def get_trends( limit: Annotated[int, Field(ge=10, le=200, description="关键词返回上限")] = 50, ) -> dict[str, Any]: _caller_user_id() + _enforce_tool_scopes("get_trends") try: topics = await duckdb_service.run_query(lambda: duckdb_service.query_trend_topics(days=days)) keywords = await duckdb_service.run_query(lambda: duckdb_service.query_keyword_cloud(days=days, limit=limit)) @@ -215,6 +275,7 @@ async def score_items( items: Annotated[list[ScoringRequestItem], Field(min_length=1, max_length=50, description="待打分条目,1-50 条")], ) -> dict[str, Any]: _caller_user_id() + _enforce_tool_scopes("score_items") inputs = [request_item_to_scoring_input(item) for item in items] scored = run_score_items(inputs) return build_scoring_response(scored).model_dump(mode="json") diff --git a/backend/app/middleware/rate_limit.py b/backend/app/middleware/rate_limit.py index 31da1b40..c02dcab7 100644 --- a/backend/app/middleware/rate_limit.py +++ b/backend/app/middleware/rate_limit.py @@ -27,6 +27,12 @@ _RATE_RULES: list[tuple[str, int, int]] = [ ("/api/v1/auth", 20, 60), ("/api/v1/creation", 30, 60), + # MCP OAuth 授权服务器端点(未认证面,公网可达):DCR 收紧防刷注册, + # authorize/token 覆盖正常客户端节奏(含 PKCE 重试与 refresh 轮换) + ("/authorize", 30, 60), + ("/token", 30, 60), + ("/register", 10, 60), + ("/revoke", 30, 60), ("/mcp", 60, 60), ("/api/v1", 200, 60), ] diff --git a/backend/app/models/mcp_oauth.py b/backend/app/models/mcp_oauth.py new file mode 100644 index 00000000..2458b55a --- /dev/null +++ b/backend/app/models/mcp_oauth.py @@ -0,0 +1,101 @@ +"""MCP OAuth 授权服务器存储 — 动态客户端注册 / 授权码 / token。 + +TopicEye 作为 OAuth 2.1 授权服务器(AS)支撑 MCP `/mcp` 资源服务器(RS)的 +浏览器弹窗登录。三张表均属 Postgres 事务层: + +- ``mcp_oauth_clients``:RFC 7591 动态注册的客户端;secret 以 Fernet 密文 + 存储(同 oauth_providers 先例),完整元数据以 JSON 存 metadata_json。 +- ``mcp_oauth_authorizations``:一表承载两种状态——``/authorize`` 落的 + pending 授权请求(发给前端同意页 request_id),用户批准后同一行盖上 + ``code_hash`` 变成一次性授权码(status: pending/approved/denied/consumed)。 +- ``mcp_oauth_tokens``:access / refresh 共表(token_type 区分),raw token + 只回传客户端一次,库存 sha256(与 user_sessions / user_api_tokens 同模式); + ``family_id`` 串起轮换链,refresh 重放检测按 family 整体撤销。 +""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.database import Base + + +class MCPOAuthClient(Base): + """通过 DCR(POST /register)注册的 MCP 客户端。""" + + __tablename__ = "mcp_oauth_clients" + __table_args__ = (Index("ix_mcp_oauth_clients_client_id", "client_id", unique=True),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + client_id: Mapped[str] = mapped_column(String(64), nullable=False, comment="SDK 注册时生成的 uuid4") + client_name: Mapped[str | None] = mapped_column(String(200), nullable=True, comment="同意页展示的应用名") + client_secret_enc: Mapped[str | None] = mapped_column( + Text, nullable=True, comment="Fernet 密文(enc:v1:);公共客户端(method=none)为 NULL" + ) + metadata_json: Mapped[str] = mapped_column( + Text, nullable=False, comment="OAuthClientInformationFull dump(不含 secret)" + ) + registered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) + last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + +class MCPOAuthAuthorization(Base): + """/authorize 授权请求 →(批准后)一次性授权码,同一行两种身份。""" + + __tablename__ = "mcp_oauth_authorizations" + __table_args__ = ( + Index("ix_mcp_oauth_authorizations_request_id", "request_id", unique=True), + Index("ix_mcp_oauth_authorizations_code_hash", "code_hash", unique=True), + Index("ix_mcp_oauth_authorizations_client_status", "client_id", "status"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + request_id: Mapped[str] = mapped_column(String(36), nullable=False, comment="uuid4,发给前端同意页") + status: Mapped[str] = mapped_column( + String(12), default="pending", nullable=False, comment="pending / approved / denied / consumed" + ) + client_id: Mapped[str] = mapped_column(String(64), nullable=False) + user_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True) + # 发起 /authorize 的浏览器会话用户;approve/deny/info 只允许发起者本人, + # 堵住「攻击者发起、诱导受害者批准」的同意页 CSRF 路径(issue #118 圆桌裁定) + initiator_user_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True, comment="发起授权请求的用户(绑定校验)" + ) + code_hash: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="批准时生成的授权码 sha256") + scopes: Mapped[str | None] = mapped_column(Text, nullable=True, comment="JSON 数组") + state: Mapped[str | None] = mapped_column(Text, nullable=True, comment="客户端 state 原样带回") + code_challenge: Mapped[str] = mapped_column(String(128), nullable=False, comment="PKCE S256 challenge") + redirect_uri: Mapped[str] = mapped_column(String(512), nullable=False) + redirect_uri_provided_explicitly: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + resource: Mapped[str | None] = mapped_column(String(512), nullable=True, comment="RFC 8707 资源指示符") + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC) + ) + + +class MCPOAuthToken(Base): + """OAuth access / refresh token(不透明 token,库存 sha256)。""" + + __tablename__ = "mcp_oauth_tokens" + __table_args__ = ( + Index("ix_mcp_oauth_tokens_token_hash", "token_hash", unique=True), + Index("ix_mcp_oauth_tokens_family", "family_id"), + Index("ix_mcp_oauth_tokens_client_type", "client_id", "token_type"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + token_hash: Mapped[str] = mapped_column(String(64), nullable=False, comment="sha256 hex") + token_type: Mapped[str] = mapped_column(String(10), nullable=False, comment="access / refresh") + client_id: Mapped[str] = mapped_column(String(64), nullable=False) + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + family_id: Mapped[str] = mapped_column(String(36), nullable=False, comment="授权码发放起的轮换链标识") + scopes: Mapped[str | None] = mapped_column(Text, nullable=True, comment="JSON 数组") + resource: Mapped[str | None] = mapped_column(String(512), nullable=True, comment="RFC 8707 资源指示符") + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) diff --git a/backend/app/repositories/mcp_oauth_authorization_repo.py b/backend/app/repositories/mcp_oauth_authorization_repo.py new file mode 100644 index 00000000..de4dba76 --- /dev/null +++ b/backend/app/repositories/mcp_oauth_authorization_repo.py @@ -0,0 +1,72 @@ +"""Repository for MCPOAuthAuthorization — 授权请求 / 授权码生命周期查询。 + +状态机由 service 驱动;本层提供查询与两个原子状态迁移(approve / consume), +条件 UPDATE 的 rowcount 保证 pending→approved、approved→consumed 在并发 +(双开标签页各点一次授权、授权码被并发兑换)下各只有一路成功。 +""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import delete, select, update + +from app.models.mcp_oauth import MCPOAuthAuthorization +from app.repositories.base import BaseRepository + + +class MCPOAuthAuthorizationRepository(BaseRepository[MCPOAuthAuthorization]): + """MCPOAuthAuthorization CRUD + 原子状态迁移。""" + + model = MCPOAuthAuthorization + + async def get_by_request_id(self, request_id: str) -> MCPOAuthAuthorization | None: + result = await self.db.execute( + select(MCPOAuthAuthorization).where(MCPOAuthAuthorization.request_id == request_id) + ) + return result.scalar_one_or_none() + + async def get_by_code_hash(self, code_hash: str) -> MCPOAuthAuthorization | None: + result = await self.db.execute( + select(MCPOAuthAuthorization).where(MCPOAuthAuthorization.code_hash == code_hash) + ) + return result.scalar_one_or_none() + + async def transition_to_approved( + self, request_id: str, *, code_hash: str, user_id: int, expires_at: datetime + ) -> bool: + """pending → approved 的原子迁移(盖 code_hash、绑用户);False=已非 pending。""" + result = await self.db.execute( + update(MCPOAuthAuthorization) + .where(MCPOAuthAuthorization.request_id == request_id, MCPOAuthAuthorization.status == "pending") + .values( + status="approved", + code_hash=code_hash, + user_id=user_id, + expires_at=expires_at, + updated_at=datetime.now(UTC), + ) + ) + return bool(result.rowcount) + + async def consume(self, request_id: str) -> bool: + """approved → consumed 的原子迁移;返回 False 表示已被消费/非 approved。""" + result = await self.db.execute( + update(MCPOAuthAuthorization) + .where(MCPOAuthAuthorization.request_id == request_id, MCPOAuthAuthorization.status == "approved") + .values(status="consumed", updated_at=datetime.now(UTC)) + ) + return bool(result.rowcount) + + async def delete_stale(self, older_than: datetime) -> int: + """清理终态行(consumed/denied 超过保留期)与已过期的未决行,返回删除数。""" + result = await self.db.execute( + delete(MCPOAuthAuthorization).where( + ( + (MCPOAuthAuthorization.status.in_(("consumed", "denied"))) + & (MCPOAuthAuthorization.updated_at < older_than) + ) + | (MCPOAuthAuthorization.expires_at < older_than) + ) + ) + return result.rowcount or 0 diff --git a/backend/app/repositories/mcp_oauth_client_repo.py b/backend/app/repositories/mcp_oauth_client_repo.py new file mode 100644 index 00000000..5067e48c --- /dev/null +++ b/backend/app/repositories/mcp_oauth_client_repo.py @@ -0,0 +1,21 @@ +"""Repository for MCPOAuthClient — DCR 注册的 MCP OAuth 客户端查询。 + +只做查询与落库;secret 加解密在 service(secret_store),事务由调用方负责。 +""" + +from __future__ import annotations + +from sqlalchemy import select + +from app.models.mcp_oauth import MCPOAuthClient +from app.repositories.base import BaseRepository + + +class MCPOAuthClientRepository(BaseRepository[MCPOAuthClient]): + """MCPOAuthClient CRUD。""" + + model = MCPOAuthClient + + async def get_by_client_id(self, client_id: str) -> MCPOAuthClient | None: + result = await self.db.execute(select(MCPOAuthClient).where(MCPOAuthClient.client_id == client_id)) + return result.scalar_one_or_none() diff --git a/backend/app/repositories/mcp_oauth_token_repo.py b/backend/app/repositories/mcp_oauth_token_repo.py new file mode 100644 index 00000000..d30b86c8 --- /dev/null +++ b/backend/app/repositories/mcp_oauth_token_repo.py @@ -0,0 +1,45 @@ +"""Repository for MCPOAuthToken — access / refresh token 查询与 family 撤销。""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import delete, select, update + +from app.models.mcp_oauth import MCPOAuthToken +from app.repositories.base import BaseRepository + + +class MCPOAuthTokenRepository(BaseRepository[MCPOAuthToken]): + """MCPOAuthToken CRUD + family 级联撤销。""" + + model = MCPOAuthToken + + async def get_by_token_hash(self, token_hash: str) -> MCPOAuthToken | None: + result = await self.db.execute(select(MCPOAuthToken).where(MCPOAuthToken.token_hash == token_hash)) + return result.scalar_one_or_none() + + async def revoke_by_id_if_active(self, token_id: int) -> bool: + """单行原子撤销(仅当未撤销时生效);轮换并发竞争下只有一路成功。""" + result = await self.db.execute( + update(MCPOAuthToken) + .where(MCPOAuthToken.id == token_id, MCPOAuthToken.revoked_at.is_(None)) + .values(revoked_at=datetime.now(UTC)) + ) + return bool(result.rowcount) + + async def revoke_family(self, family_id: str) -> int: + """撤销整个轮换链(access + refresh);幂等,返回撤销行数。""" + result = await self.db.execute( + update(MCPOAuthToken) + .where(MCPOAuthToken.family_id == family_id, MCPOAuthToken.revoked_at.is_(None)) + .values(revoked_at=datetime.now(UTC)) + ) + return result.rowcount or 0 + + async def delete_stale(self, older_than: datetime) -> int: + """删除早已过期且被撤销超过保留期的 token 行(保留近期行便于排障)。""" + result = await self.db.execute( + delete(MCPOAuthToken).where(MCPOAuthToken.expires_at < older_than, MCPOAuthToken.revoked_at.isnot(None)) + ) + return result.rowcount or 0 diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py index 2ec70ad3..9b6da8fc 100644 --- a/backend/app/scheduler.py +++ b/backend/app/scheduler.py @@ -786,6 +786,31 @@ async def _cleanup_email_verification_codes() -> None: logger.exception("Scheduler: cleanup_email_verification_codes failed") +@track_job( + "cleanup_mcp_oauth", + name="MCP OAuth 过期数据清理", + timeout=60, + description="每日03:55清理过期的 MCP OAuth 授权行与已撤销过期的 token(终态行保留 1 天排障)", +) +async def _cleanup_mcp_oauth() -> None: + """清理过期的 MCP OAuth 授权行与已撤销过期的 token(daily)。 + + 授权码 / pending 授权请求 10 分钟过期,access 60 分钟过期;这里删的是 + 超过 1 天排障保留期的终态行(见 mcp_oauth_service.cleanup_expired)。 + """ + logger.info("Scheduler: cleanup_mcp_oauth started") + try: + from app.services.mcp_oauth_service import cleanup_expired + + async with async_session() as db: + removed = await cleanup_expired(db) + await db.commit() + logger.info("Scheduler: cleanup_mcp_oauth removed %s", removed) + return removed + except Exception: + logger.exception("Scheduler: cleanup_mcp_oauth failed") + + @track_job( "refresh_weread_stats_cache", name="微信读书统计缓存刷新", @@ -955,6 +980,15 @@ def start_scheduler() -> None: replace_existing=True, ) + # MCP OAuth 授权码/token 过期清理 at 03:55(终态行保留 1 天排障) + scheduler.add_job( + _cleanup_mcp_oauth, + trigger=CronTrigger(hour=3, minute=55), + id="cleanup_mcp_oauth", + name="Cleanup expired MCP OAuth codes and tokens", + replace_existing=True, + ) + # Trending radar: sync all trending sources every 30 minutes scheduler.add_job( _sync_all_trending, diff --git a/backend/app/services/mcp_oauth_service.py b/backend/app/services/mcp_oauth_service.py new file mode 100644 index 00000000..c00ae662 --- /dev/null +++ b/backend/app/services/mcp_oauth_service.py @@ -0,0 +1,412 @@ +"""MCP OAuth 授权服务器业务编排。 + +实现官方 SDK ``OAuthAuthorizationServerProvider`` 协议背后的全部状态机: +动态客户端注册(secret Fernet 落库)、授权请求 → 同意 → 一次性授权码、 +access/refresh 不透明 token 签发(raw 只回传一次、库存 sha256)、 +refresh 轮换 + 重放检测(已轮换 refresh 被重放 → 撤销整个 family)、 +RFC 7009 撤销与过期清理。 + +安全语义(SDK 侧已覆盖、此处只列 provider 责任): +- 授权码单次使用:条件 UPDATE 的 rowcount 保证并发下只有一个消费者; +- PKCE S256 校验、code/refresh 归属 client、redirect_uri 一致性由 SDK + ``TokenHandler`` 完成,本层在 exchange 时对 code_challenge 再核一遍防伪造模型; +- 所有 raw token / code 都是 ``secrets.token_urlsafe(32)``(256 bit), + 库里只见 sha256(复用 ``auth_service.hash_token``)。 + +事务边界:本层只 flush,不 commit——provider 适配层(每次调用独立 +session,成功 commit)与 API 层(get_db)负责提交。 +""" + +from __future__ import annotations + +import json +import logging +import secrets +import uuid +from datetime import UTC, datetime, timedelta +from urllib.parse import quote, urlparse + +from mcp.server.auth.provider import ( + AccessToken, + AuthorizationCode, + AuthorizationParams, + RefreshToken, + TokenError, + construct_redirect_uri, +) +from mcp.shared.auth import OAuthClientInformationFull, OAuthToken + +from app.core.config import settings +from app.models.mcp_oauth import MCPOAuthAuthorization, MCPOAuthToken +from app.repositories.mcp_oauth_authorization_repo import MCPOAuthAuthorizationRepository +from app.repositories.mcp_oauth_client_repo import MCPOAuthClientRepository +from app.repositories.mcp_oauth_token_repo import MCPOAuthTokenRepository +from app.services.auth_service import hash_token +from app.services.secret_store import decrypt_secret, encrypt_secret + +logger = logging.getLogger(__name__) + + +def _now() -> datetime: + return datetime.now(UTC) + + +def _epoch(dt: datetime) -> int: + """datetime → epoch 秒(int;SDK 的 token 模型 expires_at 是整数)。 + + SQLite 测试后端会丢 tzinfo,按 UTC 补回。 + """ + if dt.tzinfo is None: + dt = dt.replace(tzinfo=UTC) + return int(dt.timestamp()) + + +def _expired(expires_at: datetime) -> bool: + return _epoch(expires_at) < _now().timestamp() + + +def _site_base() -> str: + """站点根 URL;未配置时回退本地开发地址(与 app.mcp.server._base_url 同口径)。""" + return (settings.SITE_BASE_URL or "http://127.0.0.1:8000").rstrip("/") + + +def _consent_url(request_id: str) -> str: + return f"{_site_base()}{settings.MCP_OAUTH_CONSENT_PATH}?request_id={quote(request_id)}" + + +def _load_scopes(raw: str | None) -> list[str]: + return json.loads(raw) if raw else [] + + +# ── 动态客户端注册(RFC 7591;SDK 已生成 client_id / secret,本层只持久化)── + + +async def register_client(db, client_info: OAuthClientInformationFull) -> None: + """落库 DCR 客户端。secret 以 Fernet 密文单独存,元数据存 JSON。""" + repo = MCPOAuthClientRepository(db) + await repo.create( + client_id=client_info.client_id, + client_name=client_info.client_name, + client_secret_enc=encrypt_secret(client_info.client_secret), + metadata_json=client_info.model_dump_json(exclude={"client_secret"}), + ) + + +async def get_client(db, client_id: str) -> OAuthClientInformationFull | None: + """按 client_id 取回注册信息;secret 解密后放回模型(ClientAuthenticator 用它比对)。""" + repo = MCPOAuthClientRepository(db) + row = await repo.get_by_client_id(client_id) + if row is None: + return None + data = json.loads(row.metadata_json) + data["client_secret"] = decrypt_secret(row.client_secret_enc) + try: + return OAuthClientInformationFull.model_validate(data) + except Exception: + logger.exception("MCP OAuth client %s 元数据损坏,无法还原", client_id) + return None + + +# ── 授权请求 → 同意页 → 授权码 ───────────────────────────────────────── + + +async def create_authorization_request( + db, + *, + client_id: str, + params: AuthorizationParams, + initiator_user_id: int | None, +) -> str: + """/authorize 落一条 pending 授权请求,返回前端同意页 URL。 + + ``initiator_user_id`` 是发起 /authorize 的浏览器会话用户(由宿主包装 + 路由解析并经 contextvar 传入);后续 info/approve/deny 只允许发起者 + 本人操作,堵住「攻击者发起、诱导受害者批准」的同意页 CSRF 路径。 + """ + request_id = str(uuid.uuid4()) + row = MCPOAuthAuthorization( + request_id=request_id, + status="pending", + client_id=client_id, + initiator_user_id=initiator_user_id, + scopes=json.dumps(params.scopes or []), + state=params.state, + code_challenge=params.code_challenge, + redirect_uri=str(params.redirect_uri), + redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly, + resource=params.resource, + expires_at=_now() + timedelta(seconds=settings.MCP_OAUTH_CODE_TTL_SECONDS), + ) + db.add(row) + await db.flush() + return _consent_url(request_id) + + +async def _pending_row(db, request_id: str, *, acting_user_id: int) -> MCPOAuthAuthorization | None: + """取 pending、未过期且**发起者是本人**的授权请求行;其余一律视为不存在。 + + 发起者为 NULL 的行(理论上只有绕过包装路由才会产生)同样拒绝—— + fail-closed,宁可要求重新发起也不给 approve-any 留缝。 + """ + row = await MCPOAuthAuthorizationRepository(db).get_by_request_id(request_id) + if row is None or row.status != "pending" or _expired(row.expires_at): + return None + if row.initiator_user_id is None or row.initiator_user_id != acting_user_id: + return None + return row + + +async def get_authorization_info(db, request_id: str, *, viewer_user_id: int) -> dict | None: + """同意页展示信息:应用名、主页、请求的 scope 与回调目标主机。""" + row = await _pending_row(db, request_id, acting_user_id=viewer_user_id) + if row is None: + return None + client = await get_client(db, row.client_id) + return { + "request_id": row.request_id, + "client_name": (client.client_name if client else None) or row.client_id, + "client_uri": client.client_uri if client else None, + "scopes": _load_scopes(row.scopes), + "redirect_host": urlparse(row.redirect_uri).hostname or "", + } + + +async def approve_authorization(db, request_id: str, user_id: int) -> tuple[str, str] | None: + """用户点「授权」:盖 code_hash、记 user,返回 (raw_code, 回调跳转地址)。 + + 只允许发起者本人批准(_pending_row 校验 initiator);pending→approved + 用条件 UPDATE 原子迁移:双开标签页并发点「授权」只有一路拿到有效 + code(另一路 404),不会出现两枚 code 相互覆盖。 + """ + row = await _pending_row(db, request_id, acting_user_id=user_id) + if row is None: + return None + code = secrets.token_urlsafe(32) + # 批准后重新起算授权码 TTL + ok = await MCPOAuthAuthorizationRepository(db).transition_to_approved( + request_id, + code_hash=hash_token(code), + user_id=user_id, + expires_at=_now() + timedelta(seconds=settings.MCP_OAUTH_CODE_TTL_SECONDS), + ) + if not ok: + return None + redirect_to = construct_redirect_uri(row.redirect_uri, code=code, state=row.state) + return code, redirect_to + + +async def deny_authorization(db, request_id: str, *, acting_user_id: int) -> str | None: + """用户点「拒绝」:按 RFC 6749 带 error=access_denied 回调(仅发起者)。""" + row = await _pending_row(db, request_id, acting_user_id=acting_user_id) + if row is None: + return None + row.status = "denied" + await db.flush() + return construct_redirect_uri(row.redirect_uri, error="access_denied", state=row.state) + + +# ── 授权码交换 / token 签发与轮换 ────────────────────────────────────── + + +async def load_authorization_code(db, *, client_id: str, code: str) -> AuthorizationCode | None: + authz_repo = MCPOAuthAuthorizationRepository(db) + row = await authz_repo.get_by_code_hash(hash_token(code)) + if row is not None and row.status == "consumed": + # 已消费的授权码再次出现 = 泄密信号(能走到消费完说明第一次兑换成功, + # 而携带正确 code 的只有客户端或截获者)。当前只告警记录;整族撤销 + # 与 family 关联列随完整 scope 模型排期(issue #118,写工具前置 DoD)。 + logger.warning( + "MCP OAuth 授权码重放信号:request_id=%s client=%s(token 未连坐撤销,见 issue #118)", + row.request_id, + row.client_id, + ) + if row is None or row.status != "approved" or row.client_id != client_id or _expired(row.expires_at): + return None + return AuthorizationCode( + code=code, + scopes=_load_scopes(row.scopes), + expires_at=_epoch(row.expires_at), + client_id=row.client_id, + code_challenge=row.code_challenge, + redirect_uri=row.redirect_uri, + redirect_uri_provided_explicitly=row.redirect_uri_provided_explicitly, + resource=row.resource, + subject=str(row.user_id) if row.user_id is not None else None, + ) + + +async def _issue_token_pair( + db, + *, + client_id: str, + user_id: int, + scopes: list[str], + resource: str | None, + family_id: str | None = None, +) -> OAuthToken: + """签发 access + refresh 一对(同一 family),raw 值只出现在返回值里。""" + family_id = family_id or str(uuid.uuid4()) + now = _now() + access_raw = secrets.token_urlsafe(32) + refresh_raw = secrets.token_urlsafe(32) + scopes_json = json.dumps(scopes) if scopes else None + common = { + "client_id": client_id, + "user_id": user_id, + "family_id": family_id, + "scopes": scopes_json, + "resource": resource, + } + db.add( + MCPOAuthToken( + token_hash=hash_token(access_raw), + token_type="access", + expires_at=now + timedelta(seconds=settings.MCP_OAUTH_ACCESS_TOKEN_TTL_SECONDS), + **common, + ) + ) + db.add( + MCPOAuthToken( + token_hash=hash_token(refresh_raw), + token_type="refresh", + expires_at=now + timedelta(days=settings.MCP_OAUTH_REFRESH_TOKEN_DAYS), + **common, + ) + ) + await db.flush() + return OAuthToken( + access_token=access_raw, + expires_in=settings.MCP_OAUTH_ACCESS_TOKEN_TTL_SECONDS, + scope=" ".join(scopes) if scopes else None, + refresh_token=refresh_raw, + ) + + +async def exchange_authorization_code(db, auth_code: AuthorizationCode) -> OAuthToken: + """消费授权码换 token 对;单次使用由条件 UPDATE 保证。""" + authz_repo = MCPOAuthAuthorizationRepository(db) + row = await authz_repo.get_by_code_hash(hash_token(auth_code.code)) + if row is None or row.user_id is None: + raise TokenError("invalid_grant", "authorization code does not exist") + if auth_code.code_challenge != row.code_challenge: + # load 只从本行构造模型,challenge 不一致即伪造 + raise TokenError("invalid_grant", "authorization code challenge mismatch") + if not await authz_repo.consume(row.request_id): + raise TokenError("invalid_grant", "authorization code already used") + return await _issue_token_pair( + db, + client_id=row.client_id, + user_id=row.user_id, + scopes=_load_scopes(row.scopes), + resource=row.resource, + ) + + +async def load_refresh_token(db, *, client_id: str, token: str) -> RefreshToken | None: + """加载 refresh;已撤销(含已轮换)的重放会连坐撤销整个 family。""" + token_repo = MCPOAuthTokenRepository(db) + row = await token_repo.get_by_token_hash(hash_token(token)) + if row is None or row.token_type != "refresh" or row.client_id != client_id: + return None + if row.revoked_at is not None: + revoked = await token_repo.revoke_family(row.family_id) + logger.warning("MCP OAuth refresh token 重放检测:family %s 撤销 %d 行", row.family_id, revoked) + return None + if _expired(row.expires_at): + return None + return RefreshToken( + token=token, + client_id=row.client_id, + scopes=_load_scopes(row.scopes), + expires_at=_epoch(row.expires_at), + resource=row.resource, + subject=str(row.user_id), + ) + + +async def exchange_refresh_token(db, refresh_token: RefreshToken, scopes: list[str]) -> OAuthToken: + """轮换 refresh:原子撤销旧行后签发新对(同 family)。""" + token_repo = MCPOAuthTokenRepository(db) + row = await token_repo.get_by_token_hash(hash_token(refresh_token.token)) + if row is None or row.token_type != "refresh" or row.user_id is None: + raise TokenError("invalid_grant", "refresh token does not exist") + if not await token_repo.revoke_by_id_if_active(row.id): + # 并发轮换:另一路已消费掉这枚 refresh + raise TokenError("invalid_grant", "refresh token has been rotated") + return await _issue_token_pair( + db, + client_id=row.client_id, + user_id=row.user_id, + scopes=scopes, + resource=row.resource, + family_id=row.family_id, + ) + + +# ── 资源服务器侧:token 解析与撤销 ──────────────────────────────────── + + +async def resolve_access_token(db, token: str) -> AccessToken | None: + """给 TopicEyeTokenVerifier 用:raw access token → SDK AccessToken。 + + ``client_id`` 填 ``str(user.id)``(工具层 _caller_user_id 的约定), + OAuth 客户端身份放 ``subject``/``claims`` 供审计。 + """ + row = await _active_access_row(db, token) + if row is None: + return None + return AccessToken( + token=token, + client_id=str(row.user_id), + scopes=_load_scopes(row.scopes), + expires_at=_epoch(row.expires_at), + resource=row.resource, + subject=str(row.user_id), + claims={"mcp_oauth_client_id": row.client_id}, + ) + + +async def load_access_token(db, token: str) -> AccessToken | None: + """给 SDK /revoke 处理器用:``client_id`` 必须是注册的 OAuth 客户端 id, + 否则 SDK 的归属校验(token.client_id == client.client_id)永远不成立, + 撤销会被静默跳过——这是与 resolve_access_token 分开两个函数的原因。 + """ + row = await _active_access_row(db, token) + if row is None: + return None + return AccessToken( + token=token, + client_id=row.client_id, + scopes=_load_scopes(row.scopes), + expires_at=_epoch(row.expires_at), + resource=row.resource, + subject=str(row.user_id), + ) + + +async def _active_access_row(db, token: str) -> MCPOAuthToken | None: + row = await MCPOAuthTokenRepository(db).get_by_token_hash(hash_token(token)) + if row is None or row.token_type != "access" or row.revoked_at is not None or _expired(row.expires_at): + return None + return row + + +async def revoke_token_by_raw(db, token: str) -> None: + """RFC 7009:按 raw 值撤销(SDK 已校验归属 client),按 family 级联,幂等。""" + token_repo = MCPOAuthTokenRepository(db) + row = await token_repo.get_by_token_hash(hash_token(token)) + if row is not None: + await token_repo.revoke_family(row.family_id) + + +# ── 清理(调度器每日执行)──────────────────────────────────────────── + + +async def cleanup_expired(db) -> dict[str, int]: + """删除过期授权行与已撤销且过期的 token 行(保留 1 天排障窗口)。""" + cutoff = _now() - timedelta(days=1) + authorizations = await MCPOAuthAuthorizationRepository(db).delete_stale(cutoff) + tokens = await MCPOAuthTokenRepository(db).delete_stale(cutoff) + if authorizations or tokens: + logger.info("MCP OAuth 过期数据清理:authorizations=%d tokens=%d", authorizations, tokens) + return {"authorizations": authorizations, "tokens": tokens} diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 1f96cc10..a9b8efab 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -38,6 +38,7 @@ import app.models.favorite # noqa: F401 import app.models.feedback # noqa: F401 import app.models.llm_model # noqa: F401 +import app.models.mcp_oauth # noqa: F401 import app.models.model_catalog # noqa: F401 import app.models.monthly_digest # noqa: F401 import app.models.mother_topic # noqa: F401 diff --git a/backend/tests/test_mcp_oauth_http.py b/backend/tests/test_mcp_oauth_http.py new file mode 100644 index 00000000..d65cee25 --- /dev/null +++ b/backend/tests/test_mcp_oauth_http.py @@ -0,0 +1,588 @@ +"""MCP OAuth HTTP 全链路测试 — AS 端点挂在 FastAPI 根路径的真实编排。 + +复用 test_mcp_server.py 的 ASGITransport + session_manager.run 模式,把 +完整 OAuth 2.1 舞步走一遍(对 /mcp 的调用证明 OAuth token 与 RS 侧 +verifier 全链路打通): + +DCR(/register) → /authorize 302 同意页 → approve(会话认证) → /token +PKCE 换取 → **Bearer access token 打 /mcp/ 跑工具** → refresh 轮换 → +旧 refresh 重放 400 + family 连坐 → /revoke → 401;外加两个 RFC 8414 / +RFC 9728 元数据端点断言与 deny 分支、错误 PKCE 分支。 +""" + +from __future__ import annotations + +import base64 +import hashlib +import secrets +from urllib.parse import parse_qs, urlparse + +import pytest +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient +from mcp_types import ( + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + LATEST_PROTOCOL_VERSION, + PROTOCOL_VERSION_META_KEY, +) + +from app.api.v1 import mcp_oauth as mcp_oauth_api +from app.core.database import get_db +from app.mcp import auth as mcp_auth, authorize_route as mcp_authorize_route, oauth_provider as mcp_provider +from app.mcp.server import build_oauth_routes, build_streamable_http_app, mcp_server +from app.services.auth_service import create_session, create_user + +REDIRECT_URI = "http://127.0.0.1:8765/callback" +CLIENT_NAME = "http-oauth-client" + + +def _pkce() -> tuple[str, str]: + verifier = secrets.token_urlsafe(48) + challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=") + return verifier, challenge + + +def _modern_stateless_request(tool: str, arguments: dict) -> dict: + return { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": tool, + "arguments": arguments, + "_meta": { + PROTOCOL_VERSION_META_KEY: LATEST_PROTOCOL_VERSION, + CLIENT_CAPABILITIES_META_KEY: {}, + CLIENT_INFO_META_KEY: {"name": "pytest", "version": "0.0"}, + }, + }, + } + + +def _make_app(test_session_factory) -> FastAPI: + """宿主 app 的真实挂法:AS 路由在根、/mcp 子应用、consent 走 /api/v1。""" + + async def _override_get_db(): + async with test_session_factory() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + + app = FastAPI() + app.include_router(mcp_oauth_api.router, prefix="/api/v1") + app.router.routes.extend(build_oauth_routes()) + app.mount("/mcp", build_streamable_http_app()) + app.dependency_overrides[get_db] = _override_get_db + return app + + +@pytest.fixture(autouse=True) +def _patch_provider_sessions(test_session_factory, monkeypatch): + """provider / verifier / authorize 包装路由都走独立 session 工厂(测试内存库)。""" + monkeypatch.setattr(mcp_provider, "async_session", test_session_factory) + monkeypatch.setattr(mcp_auth, "async_session", test_session_factory) + monkeypatch.setattr(mcp_authorize_route, "async_session", test_session_factory) + + +async def _seed_session_user(test_session_factory) -> tuple[int, str]: + async with test_session_factory() as db: + user = await create_user(db, email="mcp-http-oauth@t", password="pw") + await db.commit() + raw_token, _row = await create_session(db, user) + await db.commit() + return user.id, raw_token + + +async def _register_client(client: AsyncClient) -> str: + resp = await client.post( + "/register", + json={ + "client_name": CLIENT_NAME, + "redirect_uris": [REDIRECT_URI], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + }, + ) + assert resp.status_code == 201, resp.text + body = resp.json() + assert body["client_id"] + assert body.get("client_secret") is None # 公共客户端(method=none)不发 secret + return body["client_id"] + + +async def _start_authorization( + client: AsyncClient, client_id: str, challenge: str, session_token: str | None = None +) -> str: + """发起 /authorize;带会话即绑定发起者(生产路径恒带,见 authorize_route)。""" + headers = {"Authorization": f"Bearer {session_token}"} if session_token else None + resp = await client.get( + "/authorize", + params={ + "client_id": client_id, + "redirect_uri": REDIRECT_URI, + "response_type": "code", + "code_challenge": challenge, + "code_challenge_method": "S256", + "state": "http-state", + }, + headers=headers, + ) + assert resp.status_code == 302, resp.text + location = resp.headers["location"] + assert "/mcp/authorize?request_id=" in location + return location.split("request_id=")[1] + + +async def _approve(client: AsyncClient, request_id: str, session_token: str) -> str: + resp = await client.post( + "/api/v1/mcp-oauth/approve", + json={"request_id": request_id}, + headers={"Authorization": f"Bearer {session_token}"}, + ) + assert resp.status_code == 200, resp.text + return resp.json()["redirect_to"] + + +async def _exchange_code(client: AsyncClient, client_id: str, code: str, verifier: str) -> dict: + resp = await client.post( + "/token", + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": REDIRECT_URI, + "client_id": client_id, + "code_verifier": verifier, + }, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["access_token"] and body["refresh_token"] + assert body["token_type"] == "Bearer" + return body + + +async def _full_grant(client: AsyncClient, session_token: str) -> tuple[str, dict]: + """DCR → authorize → approve → token,返回 (client_id, tokens)。""" + client_id = await _register_client(client) + verifier, challenge = _pkce() + request_id = await _start_authorization(client, client_id, challenge, session_token) + redirect_to = await _approve(client, request_id, session_token) + code = parse_qs(urlparse(redirect_to).query)["code"][0] + tokens = await _exchange_code(client, client_id, code, verifier) + return client_id, tokens + + +# ── 完整舞蹈 ─────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_full_oauth_dance_to_mcp_tool_call(test_session_factory): + _user_id, session_token = await _seed_session_user(test_session_factory) + transport = ASGITransport(app=_make_app(test_session_factory)) + async with ( + mcp_server.session_manager.run(), + AsyncClient(transport=transport, base_url="http://test") as client, + ): + client_id, tokens = await _full_grant(client, session_token) + + # 同意页信息端点也顺带验证(认证 + 内容) + verifier2, challenge2 = _pkce() + request_id2 = await _start_authorization(client, client_id, challenge2, session_token) + info = await client.get( + "/api/v1/mcp-oauth/authorize-info", + params={"request_id": request_id2}, + headers={"Authorization": f"Bearer {session_token}"}, + ) + assert info.status_code == 200 + assert info.json()["client_name"] == CLIENT_NAME + assert info.json()["redirect_host"] == "127.0.0.1" + + # OAuth access token 打 /mcp/:RS verifier → 工具执行全链路 + resp = await client.post( + "/mcp/", + json=_modern_stateless_request( + "score_items", + {"items": [{"content_id": 9001, "title": "MCP OAuth 全链路", "info_density": 70}]}, + ), + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + ) + assert resp.status_code == 200, resp.text + result = resp.json()["result"] + assert result["isError"] is False + assert result["structuredContent"]["results"][0]["content_id"] == 9001 + + +@pytest.mark.asyncio +async def test_refresh_rotation_and_replay_revokes_family_http(test_session_factory): + _user_id, session_token = await _seed_session_user(test_session_factory) + transport = ASGITransport(app=_make_app(test_session_factory)) + async with ( + mcp_server.session_manager.run(), + AsyncClient(transport=transport, base_url="http://test") as client, + ): + client_id, tokens = await _full_grant(client, session_token) + + # 轮换成功 + first = await client.post( + "/token", + data={"grant_type": "refresh_token", "refresh_token": tokens["refresh_token"], "client_id": client_id}, + ) + assert first.status_code == 200, first.text + rotated = first.json() + + # 旧 refresh 重放 → 400 invalid_grant,且 family 连坐(新 access 也失效) + replay = await client.post( + "/token", + data={"grant_type": "refresh_token", "refresh_token": tokens["refresh_token"], "client_id": client_id}, + ) + assert replay.status_code == 400 + assert replay.json()["error"] == "invalid_grant" + + dead = await client.post( + "/mcp/", + json=_modern_stateless_request("score_items", {"items": [{"content_id": 1}]}), + headers={"Authorization": f"Bearer {rotated['access_token']}"}, + ) + assert dead.status_code == 401 + + +@pytest.mark.asyncio +async def test_revoke_kills_access_token(test_session_factory): + _user_id, session_token = await _seed_session_user(test_session_factory) + transport = ASGITransport(app=_make_app(test_session_factory)) + async with ( + mcp_server.session_manager.run(), + AsyncClient(transport=transport, base_url="http://test") as client, + ): + client_id, tokens = await _full_grant(client, session_token) + + # SDK RevocationRequest 把 client_secret 声明为必填(可空)——公共 + # 客户端按惯例带空值;authenticator 对 method=none 的客户端忽略该字段 + revoked = await client.post( + "/revoke", + data={ + "token": tokens["access_token"], + "client_id": client_id, + "token_type_hint": "access_token", + "client_secret": "", + }, + ) + assert revoked.status_code == 200 # RFC 7009:未知 token 也 200,幂等 + + dead = await client.post( + "/mcp/", + json=_modern_stateless_request("score_items", {"items": [{"content_id": 1}]}), + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + ) + assert dead.status_code == 401 + + +# ── 负向分支 ─────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_wrong_pkce_verifier_rejected(test_session_factory): + _user_id, session_token = await _seed_session_user(test_session_factory) + transport = ASGITransport(app=_make_app(test_session_factory)) + async with AsyncClient(transport=transport, base_url="http://test") as client: + client_id = await _register_client(client) + _verifier, challenge = _pkce() + request_id = await _start_authorization(client, client_id, challenge, session_token) + redirect_to = await _approve(client, request_id, session_token) + code = parse_qs(urlparse(redirect_to).query)["code"][0] + + wrong = secrets.token_urlsafe(48) + resp = await client.post( + "/token", + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": REDIRECT_URI, + "client_id": client_id, + "code_verifier": wrong, + }, + ) + assert resp.status_code == 400 + assert resp.json()["error"] == "invalid_grant" + + +@pytest.mark.asyncio +async def test_authorization_code_single_use_http(test_session_factory): + _user_id, session_token = await _seed_session_user(test_session_factory) + transport = ASGITransport(app=_make_app(test_session_factory)) + async with AsyncClient(transport=transport, base_url="http://test") as client: + client_id, _tokens = await _full_grant(client, session_token) + verifier, challenge = _pkce() + request_id = await _start_authorization(client, client_id, challenge, session_token) + redirect_to = await _approve(client, request_id, session_token) + code = parse_qs(urlparse(redirect_to).query)["code"][0] + + first = await _exchange_code(client, client_id, code, verifier) + assert first["access_token"] + second = await client.post( + "/token", + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": REDIRECT_URI, + "client_id": client_id, + "code_verifier": verifier, + }, + ) + assert second.status_code == 400 + assert second.json()["error"] == "invalid_grant" + + +@pytest.mark.asyncio +async def test_deny_flow(test_session_factory): + _user_id, session_token = await _seed_session_user(test_session_factory) + transport = ASGITransport(app=_make_app(test_session_factory)) + async with AsyncClient(transport=transport, base_url="http://test") as client: + client_id = await _register_client(client) + _verifier, challenge = _pkce() + request_id = await _start_authorization(client, client_id, challenge, session_token) + + resp = await client.post( + "/api/v1/mcp-oauth/deny", + json={"request_id": request_id}, + headers={"Authorization": f"Bearer {session_token}"}, + ) + assert resp.status_code == 200 + redirect_to = resp.json()["redirect_to"] + assert "error=access_denied" in redirect_to and "state=http-state" in redirect_to + + # deny 后请求终态:approve / authorize-info 都 404 + gone = await client.post( + "/api/v1/mcp-oauth/approve", + json={"request_id": request_id}, + headers={"Authorization": f"Bearer {session_token}"}, + ) + assert gone.status_code == 404 + + +@pytest.mark.asyncio +async def test_consent_endpoints_require_auth(test_session_factory): + transport = ASGITransport(app=_make_app(test_session_factory)) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.get("/api/v1/mcp-oauth/authorize-info", params={"request_id": "whatever"}) + assert resp.status_code == 401 + resp = await client.post("/api/v1/mcp-oauth/approve", json={"request_id": "whatever"}) + assert resp.status_code == 401 + + +@pytest.mark.asyncio +async def test_authorize_rejects_unregistered_redirect_uri(test_session_factory): + """开放重定向守卫:redirect_uri 不在客户端注册列表内必须 400,不落库。""" + _user_id, session_token = await _seed_session_user(test_session_factory) + transport = ASGITransport(app=_make_app(test_session_factory)) + _verifier, challenge = _pkce() + async with AsyncClient(transport=transport, base_url="http://test") as client: + client_id = await _register_client(client) + resp = await client.get( + "/authorize", + headers={"Authorization": f"Bearer {session_token}"}, + params={ + "client_id": client_id, + "redirect_uri": "http://127.0.0.1:9999/evil-callback", + "response_type": "code", + "code_challenge": challenge, + "code_challenge_method": "S256", + }, + ) + assert resp.status_code == 400 + assert resp.json()["error"] == "invalid_request" + assert "not registered" in resp.json()["error_description"] + + +@pytest.mark.asyncio +async def test_token_rejects_redirect_uri_mismatch(test_session_factory): + """/token 的 redirect_uri 必须与 /authorize 时一致(SDK 比对存储行)。""" + _user_id, session_token = await _seed_session_user(test_session_factory) + transport = ASGITransport(app=_make_app(test_session_factory)) + async with AsyncClient(transport=transport, base_url="http://test") as client: + client_id = await _register_client(client) + verifier, challenge = _pkce() + request_id = await _start_authorization(client, client_id, challenge, session_token) + redirect_to = await _approve(client, request_id, session_token) + code = parse_qs(urlparse(redirect_to).query)["code"][0] + + resp = await client.post( + "/token", + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": "http://127.0.0.1:8765/other", + "client_id": client_id, + "code_verifier": verifier, + }, + ) + assert resp.status_code == 400 + assert resp.json()["error"] == "invalid_request" + + +@pytest.mark.asyncio +async def test_authorize_rejects_unknown_client(test_session_factory): + _user_id, session_token = await _seed_session_user(test_session_factory) + transport = ASGITransport(app=_make_app(test_session_factory)) + _verifier, challenge = _pkce() + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.get( + "/authorize", + headers={"Authorization": f"Bearer {session_token}"}, + params={ + "client_id": "no-such-client", + "redirect_uri": REDIRECT_URI, + "response_type": "code", + "code_challenge": challenge, + "code_challenge_method": "S256", + }, + ) + # client 未注册 → 直接 400 JSON(无回调可跳) + assert resp.status_code == 400 + assert resp.json()["error"] == "invalid_request" + + +# ── 元数据端点(RFC 8414 / RFC 9728)─────────────────────────────────── + + +@pytest.mark.asyncio +async def test_authorization_server_metadata(test_session_factory): + transport = ASGITransport(app=_make_app(test_session_factory)) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.get("/.well-known/oauth-authorization-server") + assert resp.status_code == 200 + meta = resp.json() + assert meta["authorization_endpoint"].endswith("/authorize") + assert meta["token_endpoint"].endswith("/token") + assert meta["registration_endpoint"].endswith("/register") + assert meta["revocation_endpoint"].endswith("/revoke") + assert "S256" in meta["code_challenge_methods_supported"] + assert "authorization_code" in meta["grant_types_supported"] + assert "refresh_token" in meta["grant_types_supported"] + + +@pytest.mark.asyncio +async def test_protected_resource_metadata(test_session_factory): + transport = ASGITransport(app=_make_app(test_session_factory)) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.get("/.well-known/oauth-protected-resource/mcp") + assert resp.status_code == 200 + meta = resp.json() + assert meta["resource"].endswith("/mcp") + assert meta["authorization_servers"], "应指回 AS(issuer)" + + +@pytest.mark.asyncio +async def test_static_token_still_works_alongside_oauth(test_session_factory): + """存量静态 token(个人 API token)在 OAuth 上线后行为不变。""" + from app.services.auth_service import create_api_token + + async with test_session_factory() as db: + user = await create_user(db, email="mcp-static@t", password="pw") + await db.commit() + raw_token, _record = await create_api_token(db, user_id=user.id, name="http static") + await db.commit() + + transport = ASGITransport(app=_make_app(test_session_factory)) + async with ( + mcp_server.session_manager.run(), + AsyncClient(transport=transport, base_url="http://test") as client, + ): + resp = await client.post( + "/mcp/", + json=_modern_stateless_request("score_items", {"items": [{"content_id": 7}]}), + headers={"Authorization": f"Bearer {raw_token}"}, + ) + assert resp.status_code == 200 + assert resp.json()["result"]["isError"] is False + + +# ── 发起者绑定(issue #116 圆桌裁定 ①:堵 approve-any 冒用路径)──────── + + +@pytest.mark.asyncio +async def test_authorize_anonymous_redirects_to_login(test_session_factory): + """匿名浏览器发起 /authorize → 302 登录页,returnTo 带完整查询串可续传。""" + transport = ASGITransport(app=_make_app(test_session_factory)) + _verifier, challenge = _pkce() + async with AsyncClient(transport=transport, base_url="http://test") as client: + client_id = await _register_client(client) + resp = await client.get( + "/authorize", + params={ + "client_id": client_id, + "redirect_uri": REDIRECT_URI, + "response_type": "code", + "code_challenge": challenge, + "code_challenge_method": "S256", + "state": "anon", + }, + ) + assert resp.status_code == 302 + location = resp.headers["location"] + assert "/login?returnTo=" in location + # returnTo 是编码后的完整 /authorize 地址(含查询串) + assert "%2Fauthorize%3F" in location + + +@pytest.mark.asyncio +async def test_authorize_post_anonymous_rejected(test_session_factory): + """POST /authorize 匿名 → 400(POST 无法断点续传,不做登录重定向)。""" + transport = ASGITransport(app=_make_app(test_session_factory)) + _verifier, challenge = _pkce() + async with AsyncClient(transport=transport, base_url="http://test") as client: + client_id = await _register_client(client) + resp = await client.post( + "/authorize", + data={ + "client_id": client_id, + "redirect_uri": REDIRECT_URI, + "response_type": "code", + "code_challenge": challenge, + "code_challenge_method": "S256", + }, + ) + assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_only_initiator_can_view_or_approve(test_session_factory): + """发起者绑定:B 看不到 A 发起的请求(info 404),approve 也 404;A 本人正常。""" + from app.services.auth_service import create_session, create_user + + async with test_session_factory() as db: + alice = await create_user(db, email="bind-alice@t", password="pw") + bob = await create_user(db, email="bind-bob@t", password="pw") + await db.commit() + alice_token, _r1 = await create_session(db, alice) + bob_token, _r2 = await create_session(db, bob) + await db.commit() + + transport = ASGITransport(app=_make_app(test_session_factory)) + async with AsyncClient(transport=transport, base_url="http://test") as client: + client_id = await _register_client(client) + _verifier, challenge = _pkce() + request_id = await _start_authorization(client, client_id, challenge, alice_token) + + # B(非发起者)看不到也批不了 + info_b = await client.get( + "/api/v1/mcp-oauth/authorize-info", + params={"request_id": request_id}, + headers={"Authorization": f"Bearer {bob_token}"}, + ) + assert info_b.status_code == 404 + approve_b = await client.post( + "/api/v1/mcp-oauth/approve", + json={"request_id": request_id}, + headers={"Authorization": f"Bearer {bob_token}"}, + ) + assert approve_b.status_code == 404 + + # A(发起者)正常批准(code 正常签发;兑换链路由 _full_grant 覆盖) + redirect_to = await _approve(client, request_id, alice_token) + assert "code=" in redirect_to diff --git a/backend/tests/test_mcp_oauth_service.py b/backend/tests/test_mcp_oauth_service.py new file mode 100644 index 00000000..9fcf8c92 --- /dev/null +++ b/backend/tests/test_mcp_oauth_service.py @@ -0,0 +1,435 @@ +"""MCP OAuth service 测试 — 授权码 / token 轮换 / 重放检测 / verifier 混合口径。 + +口径(in-memory SQLite,``test_session_factory``): +1. DCR 客户端往返:register_client → get_client(secret 解密、元数据还原)。 +2. 授权生命周期:create → info → approve → load → exchange(单次性)→ deny。 +3. refresh 轮换 + 重放连坐:旧 refresh 重放 → family 整体撤销(新 access 一并失效)。 +4. resolve_access_token / TopicEyeTokenVerifier:OAuth token 与静态 API token 并存。 +5. cleanup_expired 清理终态行。 +""" + +from __future__ import annotations + +import base64 +import hashlib +import secrets +import uuid +from datetime import UTC, datetime, timedelta + +import pytest +from mcp.server.auth.provider import AuthorizationParams, TokenError +from mcp.shared.auth import OAuthClientInformationFull + +from app.mcp import auth as mcp_auth +from app.mcp.auth import TopicEyeTokenVerifier +from app.services import mcp_oauth_service as svc +from app.services.auth_service import create_api_token, create_session, create_user + +REDIRECT_URI = "http://127.0.0.1:8765/callback" +RESOURCE = "http://localhost:3000/mcp" + + +def _pkce() -> tuple[str, str]: + verifier = secrets.token_urlsafe(48) + challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=") + return verifier, challenge + + +def _client_info(name: str = "pytest-mcp") -> OAuthClientInformationFull: + return OAuthClientInformationFull( + client_id=str(uuid.uuid4()), + client_secret="unit-test-secret-" + secrets.token_hex(16), + redirect_uris=[REDIRECT_URI], + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + client_name=name, + token_endpoint_auth_method="none", + scope="", + ) + + +def _params(challenge: str) -> AuthorizationParams: + return AuthorizationParams( + state="st4te-xyz", + scopes=None, + code_challenge=challenge, + redirect_uri=REDIRECT_URI, + redirect_uri_provided_explicitly=True, + resource=RESOURCE, + ) + + +async def _approved_code(db, user_id: int, client: OAuthClientInformationFull) -> str: + """走完整路径拿到已批准的授权码(发起者=user_id 本人)。""" + _verifier, challenge = _pkce() + consent_url = await svc.create_authorization_request( + db, client_id=client.client_id, params=_params(challenge), initiator_user_id=user_id + ) + request_id = consent_url.split("request_id=")[1] + result = await svc.approve_authorization(db, request_id, user_id) + assert result is not None + return result[0] + + +# ── 1. DCR 客户端往返 ────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_client_roundtrip(test_session_factory): + client = _client_info() + async with test_session_factory() as db: + await svc.register_client(db, client) + await db.commit() + + loaded = await svc.get_client(db, client.client_id) + assert loaded is not None + assert loaded.client_id == client.client_id + assert loaded.client_secret == client.client_secret # 解密还原 + assert loaded.redirect_uris == client.redirect_uris + assert loaded.grant_types == client.grant_types + assert loaded.token_endpoint_auth_method == "none" + + assert await svc.get_client(db, "no-such-client") is None + + +@pytest.mark.asyncio +async def test_client_secret_not_stored_plaintext(test_session_factory): + """库里只有 Fernet 密文(enc:v1:),raw secret 不落盘。""" + client = _client_info() + async with test_session_factory() as db: + await svc.register_client(db, client) + await db.commit() + from app.repositories.mcp_oauth_client_repo import MCPOAuthClientRepository + + row = await MCPOAuthClientRepository(db).get_by_client_id(client.client_id) + assert row is not None + assert row.client_secret_enc is not None + assert row.client_secret_enc.startswith("enc:v1:") + assert client.client_secret not in row.client_secret_enc + assert client.client_secret not in row.metadata_json + + +# ── 2. 授权生命周期 ──────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_authorization_lifecycle(test_session_factory): + async with test_session_factory() as db: + user = await create_user(db, email="mcp-oauth@t", password="pw") + await db.commit() + client = _client_info() + await svc.register_client(db, client) + await db.commit() + + verifier, challenge = _pkce() + consent_url = await svc.create_authorization_request( + db, client_id=client.client_id, params=_params(challenge), initiator_user_id=user.id + ) + assert "/mcp/authorize?request_id=" in consent_url + request_id = consent_url.split("request_id=")[1] + + # 同意页信息(仅发起者可见) + info = await svc.get_authorization_info(db, request_id, viewer_user_id=user.id) + assert info is not None + assert info["client_name"] == "pytest-mcp" + assert info["redirect_host"] == "127.0.0.1" + + # 未批准前按 code 查不到 + assert await svc.load_authorization_code(db, client_id=client.client_id, code="whatever") is None + + # 批准 → 拿到 code 与回调地址 + result = await svc.approve_authorization(db, request_id, user.id) + assert result is not None + code, redirect_to = result + assert code in redirect_to and "state=st4te-xyz" in redirect_to + + # 批准后重复 approve / info 都失效(一次性) + assert await svc.approve_authorization(db, request_id, user.id) is None + assert await svc.get_authorization_info(db, request_id, viewer_user_id=user.id) is None + + # load → exchange + auth_code = await svc.load_authorization_code(db, client_id=client.client_id, code=code) + assert auth_code is not None + assert auth_code.code_challenge == challenge + assert auth_code.resource == RESOURCE + assert auth_code.subject == str(user.id) + + tokens = await svc.exchange_authorization_code(db, auth_code) + assert tokens.access_token and tokens.refresh_token + assert tokens.expires_in > 0 + + # 授权码单次性:再 exchange 抛 invalid_grant + with pytest.raises(TokenError): + await svc.exchange_authorization_code(db, auth_code) + + # access token 可解析(client_id=user.id,OAuth 客户端身份在 claims) + access = await svc.resolve_access_token(db, tokens.access_token) + assert access is not None + assert access.client_id == str(user.id) + assert access.claims == {"mcp_oauth_client_id": client.client_id} + + # 换个 client 查 code → None(归属校验) + assert await svc.load_authorization_code(db, client_id="other-client", code=code) is None + + +@pytest.mark.asyncio +async def test_authorization_deny(test_session_factory): + async with test_session_factory() as db: + user = await create_user(db, email="mcp-deny@t", password="pw") + await db.commit() + client = _client_info() + await svc.register_client(db, client) + _verifier, challenge = _pkce() + consent_url = await svc.create_authorization_request( + db, client_id=client.client_id, params=_params(challenge), initiator_user_id=user.id + ) + request_id = consent_url.split("request_id=")[1] + + redirect_to = await svc.deny_authorization(db, request_id, acting_user_id=user.id) + assert redirect_to is not None + assert "error=access_denied" in redirect_to and "state=st4te-xyz" in redirect_to + # deny 后请求终态 + assert await svc.deny_authorization(db, request_id, acting_user_id=user.id) is None + + +@pytest.mark.asyncio +async def test_authorization_code_expiry(test_session_factory): + """授权码 10 分钟 TTL:过期后 load 拒绝(SDK 侧还会再核一次 expires_at)。""" + async with test_session_factory() as db: + user = await create_user(db, email="mcp-ttl@t", password="pw") + await db.commit() + client = _client_info() + await svc.register_client(db, client) + code = await _approved_code(db, user.id, client) + + from app.repositories.mcp_oauth_authorization_repo import MCPOAuthAuthorizationRepository + + row = await MCPOAuthAuthorizationRepository(db).get_by_code_hash(svc.hash_token(code)) + assert row is not None + row.expires_at = datetime.now(UTC) - timedelta(seconds=1) + await db.flush() + + assert await svc.load_authorization_code(db, client_id=client.client_id, code=code) is None + + +@pytest.mark.asyncio +async def test_concurrent_approve_only_one_wins(test_session_factory): + """双开标签页并发批准:原子迁移后只有一路拿到有效 code(另一路 None)。""" + async with test_session_factory() as db: + user = await create_user(db, email="mcp-race@t", password="pw") + await db.commit() + client = _client_info() + await svc.register_client(db, client) + _verifier, challenge = _pkce() + consent_url = await svc.create_authorization_request( + db, client_id=client.client_id, params=_params(challenge), initiator_user_id=user.id + ) + request_id = consent_url.split("request_id=")[1] + + first = await svc.approve_authorization(db, request_id, user.id) + second = await svc.approve_authorization(db, request_id, user.id) + assert first is not None + assert second is None + # 幸存的 code 可正常兑换 + auth_code = await svc.load_authorization_code(db, client_id=client.client_id, code=first[0]) + tokens = await svc.exchange_authorization_code(db, auth_code) + assert tokens.access_token + + +# ── 3. refresh 轮换与重放连坐 ────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_refresh_rotation_and_replay_revokes_family(test_session_factory): + async with test_session_factory() as db: + user = await create_user(db, email="mcp-rotate@t", password="pw") + await db.commit() + client = _client_info() + await svc.register_client(db, client) + code = await _approved_code(db, user.id, client) + auth_code = await svc.load_authorization_code(db, client_id=client.client_id, code=code) + tokens = await svc.exchange_authorization_code(db, auth_code) + + # 轮换:旧 refresh → 新对 + refresh = await svc.load_refresh_token(db, client_id=client.client_id, token=tokens.refresh_token) + assert refresh is not None + rotated = await svc.exchange_refresh_token(db, refresh, refresh.scopes or []) + assert rotated.access_token != tokens.access_token + assert rotated.refresh_token != tokens.refresh_token + + # 新 access token 可用 + assert await svc.resolve_access_token(db, rotated.access_token) is not None + + # 重放旧 refresh → 触发 family 整体撤销(新 access 也失效) + replay = await svc.load_refresh_token(db, client_id=client.client_id, token=tokens.refresh_token) + assert replay is None + assert await svc.resolve_access_token(db, rotated.access_token) is None + assert await svc.resolve_access_token(db, tokens.access_token) is None + + +@pytest.mark.asyncio +async def test_expired_access_token_unresolvable(test_session_factory): + async with test_session_factory() as db: + user = await create_user(db, email="mcp-exp@t", password="pw") + await db.commit() + client = _client_info() + await svc.register_client(db, client) + code = await _approved_code(db, user.id, client) + auth_code = await svc.load_authorization_code(db, client_id=client.client_id, code=code) + tokens = await svc.exchange_authorization_code(db, auth_code) + + # 直接把 access 行改成已过期 + from app.repositories.mcp_oauth_token_repo import MCPOAuthTokenRepository + + row = await MCPOAuthTokenRepository(db).get_by_token_hash(svc.hash_token(tokens.access_token)) + assert row is not None + row.expires_at = datetime.now(UTC) - timedelta(seconds=1) + await db.flush() + assert await svc.resolve_access_token(db, tokens.access_token) is None + + +# ── 4. verifier 混合口径 ─────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_verifier_accepts_oauth_then_static_token(test_session_factory, monkeypatch): + monkeypatch.setattr(mcp_auth, "async_session", test_session_factory) + + async with test_session_factory() as db: + user = await create_user(db, email="mcp-mixed@t", password="pw") + await db.commit() + raw_static, _record = await create_api_token(db, user_id=user.id, name="static") + await db.commit() + + client = _client_info() + await svc.register_client(db, client) + code = await _approved_code(db, user.id, client) + auth_code = await svc.load_authorization_code(db, client_id=client.client_id, code=code) + tokens = await svc.exchange_authorization_code(db, auth_code) + await db.commit() + user_id = user.id + + verifier = TopicEyeTokenVerifier() + # OAuth access token:client_id=user.id,带过期时间 + access = await verifier.verify_token(tokens.access_token) + assert access is not None + assert access.client_id == str(user_id) + assert access.expires_at is not None + # 静态 API token 回落路径依然工作(无过期时间) + static_access = await verifier.verify_token(raw_static) + assert static_access is not None + assert static_access.client_id == str(user_id) + assert static_access.expires_at is None + # 乱码 token + assert await verifier.verify_token("garbage-token") is None + + +@pytest.mark.asyncio +async def test_verifier_accepts_session_token_still(test_session_factory, monkeypatch): + """浏览器 session token(另一类静态 token)也仍走通。""" + monkeypatch.setattr(mcp_auth, "async_session", test_session_factory) + async with test_session_factory() as db: + user = await create_user(db, email="mcp-session@t", password="pw") + await db.commit() + raw_session, _session_row = await create_session(db, user) + await db.commit() + + access = await TopicEyeTokenVerifier().verify_token(raw_session) + assert access is not None + assert access.client_id == str(user.id) + + +# ── 5. 清理 ──────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_cleanup_expired(test_session_factory): + async with test_session_factory() as db: + user = await create_user(db, email="mcp-clean@t", password="pw") + await db.commit() + client = _client_info() + await svc.register_client(db, client) + code = await _approved_code(db, user.id, client) + auth_code = await svc.load_authorization_code(db, client_id=client.client_id, code=code) + tokens = await svc.exchange_authorization_code(db, auth_code) + + # 把时间拨回 2 天前(授权行终态 + token 已撤销过期) + from sqlalchemy import update + + from app.models.mcp_oauth import MCPOAuthAuthorization, MCPOAuthToken + from app.repositories.mcp_oauth_token_repo import MCPOAuthTokenRepository + + await db.execute( + update(MCPOAuthAuthorization) + .where(MCPOAuthAuthorization.status.in_(("consumed", "denied"))) + .values(updated_at=datetime.now(UTC) - timedelta(days=2), expires_at=datetime.now(UTC) - timedelta(days=2)) + ) + refresh_row = await MCPOAuthTokenRepository(db).get_by_token_hash(svc.hash_token(tokens.refresh_token)) + await db.execute( + update(MCPOAuthToken) + .where(MCPOAuthToken.id == refresh_row.id) + .values(revoked_at=datetime.now(UTC) - timedelta(days=2), expires_at=datetime.now(UTC) - timedelta(days=2)) + ) + await db.commit() + + removed = await svc.cleanup_expired(db) + assert removed["authorizations"] >= 1 + assert removed["tokens"] >= 1 + + +# ── 发起者绑定 + 重放信号 ────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_initiator_binding_blocks_non_initiator(test_session_factory): + """发起者绑定:非发起者 info/approve/deny 全部 None;无发起者的行 fail-closed。""" + async with test_session_factory() as db: + alice = await create_user(db, email="init-alice@t", password="pw") + bob = await create_user(db, email="init-bob@t", password="pw") + await db.commit() + client = _client_info() + await svc.register_client(db, client) + _verifier, challenge = _pkce() + consent_url = await svc.create_authorization_request( + db, client_id=client.client_id, params=_params(challenge), initiator_user_id=alice.id + ) + request_id = consent_url.split("request_id=")[1] + + # B(非发起者)三路全拒绝 + assert await svc.get_authorization_info(db, request_id, viewer_user_id=bob.id) is None + assert await svc.approve_authorization(db, request_id, bob.id) is None + assert await svc.deny_authorization(db, request_id, acting_user_id=bob.id) is None + + # A(发起者)正常 + assert await svc.get_authorization_info(db, request_id, viewer_user_id=alice.id) is not None + result = await svc.approve_authorization(db, request_id, alice.id) + assert result is not None + + # 发起者为 NULL 的行(绕过包装路由才会产生)同样 fail-closed + consent_url2 = await svc.create_authorization_request( + db, client_id=client.client_id, params=_params(challenge), initiator_user_id=None + ) + request_id2 = consent_url2.split("request_id=")[1] + assert await svc.get_authorization_info(db, request_id2, viewer_user_id=alice.id) is None + assert await svc.approve_authorization(db, request_id2, alice.id) is None + + +@pytest.mark.asyncio +async def test_consumed_code_replay_logs_warning(test_session_factory, caplog): + """已消费 code 重放:返回 None 且留下泄密信号告警(连坐撤销见 issue #118)。""" + import logging + + async with test_session_factory() as db: + user = await create_user(db, email="replay@t", password="pw") + await db.commit() + client = _client_info() + await svc.register_client(db, client) + code = await _approved_code(db, user.id, client) + auth_code = await svc.load_authorization_code(db, client_id=client.client_id, code=code) + tokens = await svc.exchange_authorization_code(db, auth_code) + assert tokens.access_token + + with caplog.at_level(logging.WARNING, logger="app.services.mcp_oauth_service"): + replayed = await svc.load_authorization_code(db, client_id=client.client_id, code=code) + assert replayed is None + assert any("授权码重放信号" in r.message for r in caplog.records) diff --git a/backend/tests/test_mcp_server.py b/backend/tests/test_mcp_server.py index ff6b773e..b7679272 100644 --- a/backend/tests/test_mcp_server.py +++ b/backend/tests/test_mcp_server.py @@ -348,3 +348,60 @@ async def test_http_mcp_stateless_roundtrip(test_session_factory, monkeypatch): assert result["isError"] is False assert result["structuredContent"]["count"] == 1 assert result["structuredContent"]["results"][0]["content_id"] == 101 + + +# ── scope 中央闸门(fail-closed,issue #118 前置结构)────────────────── + + +def test_scope_gate_fail_closed_unit(): + """未登记工具直接拒绝;已登记工具 required ⊆ granted 才放行。""" + import pytest as _pytest + from mcp.server.mcpserver.exceptions import ToolError + + from app.mcp.scopes import TOOL_REQUIRED_SCOPES, require_tool_access + + # 未登记 → 拒绝(新工具不声明 scope 上不了线) + with _pytest.raises(ToolError): + require_tool_access("brand_new_tool", []) + # 已登记且无要求 → 任意 token 放行 + require_tool_access("score_items", []) + # 已登记且有权 → 放行 + require_tool_access("score_items", ["mcp:read", "mcp:write"]) + # 登记集与工具集一致性由 test_every_tool_registered_in_scope_gate 单独断言 + assert all(isinstance(v, frozenset) for v in TOOL_REQUIRED_SCOPES.values()) + + +@pytest.mark.asyncio +async def test_every_tool_registered_in_scope_gate(monkeypatch): + """CI 断言:MCP 工具集 == 闸门登记集,漏登记过不了门(教训机器化)。""" + from app.mcp.scopes import TOOL_REQUIRED_SCOPES + + monkeypatch.setattr("app.mcp.server._caller_user_id", lambda: 1) + async with Client(mcp_server) as client: + result = await client.list_tools() + assert {t.name for t in result.tools} == set(TOOL_REQUIRED_SCOPES) + + +@pytest.mark.asyncio +async def test_scope_gate_enforced_on_tool_call(monkeypatch): + """登记了所需 scope 的工具:token 无权 → 工具错误;有权 → 正常执行。""" + from mcp.server.auth.provider import AccessToken + + from app.mcp import scopes as mcp_scopes + + monkeypatch.setattr("app.mcp.server._caller_user_id", lambda: 1) + monkeypatch.setitem(mcp_scopes.TOOL_REQUIRED_SCOPES, "score_items", frozenset({"mcp:write"})) + + def _token_with(scopes: list[str]): + # get_access_token 是同步 contextvar 读取,mock 必须同步返回构造好的对象 + return AccessToken(token="t", client_id="1", scopes=scopes) + + monkeypatch.setattr("app.mcp.server.get_access_token", lambda: _token_with([])) + async with Client(mcp_server) as client: + denied = await client.call_tool("score_items", {"items": [{"content_id": 1}]}) + assert denied.is_error is True + + monkeypatch.setattr("app.mcp.server.get_access_token", lambda: _token_with(["mcp:write"])) + async with Client(mcp_server) as client: + allowed = await client.call_tool("score_items", {"items": [{"content_id": 1}]}) + assert allowed.is_error is False diff --git a/frontend/next.config.js b/frontend/next.config.js index 9cf9a527..c580482f 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -55,6 +55,14 @@ const nextConfig = { source: '/api/:path*', destination: `${backendApiUrl}/api/:path*`, }, + // MCP OAuth 授权服务器端点(issuer = SITE_BASE_URL 必须能服务这些根路径): + // RFC 8414 AS 元数据 / DCR(RFC 7591)/ 授权码 + PKCE / RFC 7009 撤销, + // 以及 RFC 9728 protected-resource 元数据(/.well-known/*) + { source: '/authorize', destination: `${backendApiUrl}/authorize` }, + { source: '/token', destination: `${backendApiUrl}/token` }, + { source: '/register', destination: `${backendApiUrl}/register` }, + { source: '/revoke', destination: `${backendApiUrl}/revoke` }, + { source: '/.well-known/:path*', destination: `${backendApiUrl}/.well-known/:path*` }, // 内置监控大盘(后端自包含 HTML 页面,纯 Canvas 图表) { source: '/dashboard', diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index f7ab4178..82ccf87c 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'; import { ArrowRight, KeyRound, LockKeyhole, Mail, Radar, UserRound } from 'lucide-react'; import { useAppContext } from '@/components/ClientLayout'; import { authApi } from '@/lib/api'; +import { sanitizeReturnTo } from '@/lib/navigation'; import { Badge, Button, Panel, cx } from '@/components/ui'; type AuthMode = 'login' | 'register'; @@ -46,6 +47,13 @@ export default function LoginPage() { const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [oauthProviders, setOauthProviders] = useState([]); + // 登录后的回跳地址(MCP 授权同意页等场景带 returnTo 进来),只接受站内路径 + const [returnTo, setReturnTo] = useState('/'); + + useEffect(() => { + const raw = new URLSearchParams(window.location.search).get('returnTo'); + setReturnTo(sanitizeReturnTo(raw)); + }, []); // 拉取后端已启用的 OAuth provider 列表,决定是否渲染对应按钮 useEffect(() => { @@ -105,7 +113,13 @@ export default function LoginPage() { verification_code: verificationCode.trim(), }); applyAuthSession(session); - router.push('/'); + if (returnTo && returnTo !== '/') { + // returnTo 可能指向后端路径(如 /authorize,经 next rewrite 代理), + // 必须整页导航——客户端路由推不到 rewrite 目标 + window.location.href = returnTo; + } else { + router.push('/'); + } } catch (err) { setError(err instanceof Error ? err.message : mode === 'login' ? '登录失败' : '注册失败'); } finally { diff --git a/frontend/src/app/mcp/authorize/page.tsx b/frontend/src/app/mcp/authorize/page.tsx new file mode 100644 index 00000000..c93f3715 --- /dev/null +++ b/frontend/src/app/mcp/authorize/page.tsx @@ -0,0 +1,181 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { ArrowRight, Radar, ShieldCheck, XCircle } from 'lucide-react'; +import { useAppContext } from '@/components/ClientLayout'; +import { mcpOAuthApi } from '@/lib/api'; +import type { McpAuthorizeInfo } from '@/lib/api'; +import { Badge, Button, Panel } from '@/components/ui'; + +type Status = 'loading' | 'ready' | 'error' | 'acting'; + +/** + * MCP OAuth 同意页。 + * + * MCP 客户端(Claude Code / ZCode 等)发起 /authorize 后,后端 302 到 + * 本页 ?request_id=xxx。页面按登录态分流:未登录跳 /login?returnTo=... + * (登录后回来继续),已登录展示待授权应用与数据范围,用户「授权 / + * 拒绝」后整页跳转回客户端回调地址(带一次性 code 或 access_denied)。 + * + * 授权请求本身 10 分钟过期、一次性消费;重复进入/过期请求直接报错。 + */ +export default function McpAuthorizePage() { + const router = useRouter(); + const { currentUser } = useAppContext(); + const [status, setStatus] = useState('loading'); + const [errorMsg, setErrorMsg] = useState(''); + const [info, setInfo] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function run() { + const requestId = new URLSearchParams(window.location.search).get('request_id'); + if (!requestId) { + if (!cancelled) { + setErrorMsg('缺少授权请求参数,请从 AI 客户端重新发起连接'); + setStatus('error'); + } + return; + } + + try { + const result = await mcpOAuthApi.authorizeInfo(requestId); + if (!cancelled) { + setInfo(result); + setStatus('ready'); + } + } catch (err) { + if (cancelled) return; + const apiErr = err as Error & { status?: number; isAuthError?: boolean }; + if (apiErr.isAuthError || apiErr.status === 401) { + // 未登录:带上完整当前地址作为 returnTo,登录后回来继续授权 + const here = encodeURIComponent(window.location.pathname + window.location.search); + router.replace(`/login?returnTo=${here}`); + return; + } + setErrorMsg(apiErr.status === 404 ? '授权请求不存在、已过期或已被处理,请从 AI 客户端重新发起' : apiErr.message); + setStatus('error'); + } + } + + run(); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const decide = async (action: 'approve' | 'deny') => { + if (!info || status === 'acting') return; + setStatus('acting'); + try { + const decision = action === 'approve' + ? await mcpOAuthApi.approve(info.request_id) + : await mcpOAuthApi.deny(info.request_id); + // 整页跳转回 MCP 客户端回调地址(带 code / error 参数) + window.location.href = decision.redirect_to; + } catch (err) { + const apiErr = err as Error & { status?: number }; + setErrorMsg(apiErr.status === 404 ? '授权请求已过期或已被处理,请从 AI 客户端重新发起' : apiErr.message); + setStatus('error'); + } + }; + + return ( +
+ + {status === 'loading' && ( +
+
+ +
+

正在加载授权请求...

+

请稍候

+
+ )} + + {(status === 'ready' || status === 'acting') && info && ( +
+
+
+ +
+
+

连接 AI 客户端

+

TopicEye 选题雷达 · MCP 授权

+
+
+ +
+

授权对象

+

+ {info.client_name} +

+ {info.redirect_host && ( +

回调地址:{info.redirect_host}

+ )} + {/* DCR 开放注册,客户端名可任意取(含仿冒官方)——知情权必须显式给出 */} +

+ 该应用为未经 TopicEye 审核的第三方客户端,请确认你信任它再授权。 +

+
+ +
+

授权后,该客户端可以代表你:

+
    +
  • + 读 + 读取今日精选选题、选题日报(缺失时会自动生成当日快照)、话题趋势与关键词 +
  • +
  • + 算 + 对你提交的候选选题跑六维打分并返回评分明细 +
  • +
+

+ 授权可随时在 AI 客户端断开(token 撤销即失效);不会暴露你的登录密码。 + {currentUser ? ` 当前账号:${currentUser.display_name || currentUser.email}。` : ''} +

+
+ +
+ + +
+
+ )} + + {status === 'error' && ( +
+
+ +
+

无法完成授权

+

{errorMsg}

+ +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/ClientLayout.tsx b/frontend/src/components/ClientLayout.tsx index 0250c71c..45e842c1 100644 --- a/frontend/src/components/ClientLayout.tsx +++ b/frontend/src/components/ClientLayout.tsx @@ -10,7 +10,8 @@ import type { PrefetchData } from '@/lib/server-prefetch'; // Backward compat: 38 个消费者从 @/components/ClientLayout 导入 useAppContext export { useAppContext }; -const CHROMELESS_PATHS = new Set(['/login']); +// 无侧边栏的整屏页:登录页与 MCP 授权同意页(弹窗场景,只要居中卡片) +const CHROMELESS_PATHS = new Set(['/login', '/mcp/authorize']); const ADMIN_PATH_PREFIX = '/admin'; /** diff --git a/frontend/src/lib/__tests__/mcp-oauth.test.ts b/frontend/src/lib/__tests__/mcp-oauth.test.ts new file mode 100644 index 00000000..73abc638 --- /dev/null +++ b/frontend/src/lib/__tests__/mcp-oauth.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mcpOAuthApi } from '@/lib/api'; +import { sanitizeReturnTo } from '@/lib/navigation'; + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + headers: { 'Content-Type': 'application/json' }, + }); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('mcpOAuthApi consent endpoints', () => { + it('fetches authorize-info with the request id encoded', async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ request_id: 'r1', client_name: 'Claude', client_uri: null, scopes: [], redirect_host: '127.0.0.1' }), + ); + vi.stubGlobal('fetch', fetchMock); + + const info = await mcpOAuthApi.authorizeInfo('r 1/2'); + expect(info.client_name).toBe('Claude'); + expect(fetchMock).toHaveBeenCalledWith( + '/api/v1/mcp-oauth/authorize-info?request_id=r%201%2F2', + expect.any(Object), + ); + }); + + it('posts approve/deny decisions as JSON bodies', async () => { + const fetchMock = vi.fn().mockImplementation(() => + Promise.resolve(jsonResponse({ redirect_to: 'http://127.0.0.1:1/cb?code=x' })), + ); + vi.stubGlobal('fetch', fetchMock); + + await mcpOAuthApi.approve('req-1'); + await mcpOAuthApi.deny('req-1'); + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + '/api/v1/mcp-oauth/approve', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ request_id: 'req-1' }), + }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + '/api/v1/mcp-oauth/deny', + expect.objectContaining({ method: 'POST' }), + ); + }); +}); + +describe('sanitizeReturnTo', () => { + it('keeps in-site paths with query strings', () => { + expect(sanitizeReturnTo('/mcp/authorize?request_id=abc')).toBe('/mcp/authorize?request_id=abc'); + expect(sanitizeReturnTo('/profile')).toBe('/profile'); + }); + + it('rejects open-redirect shapes and falls back to home', () => { + expect(sanitizeReturnTo('//evil.com')).toBe('/'); + expect(sanitizeReturnTo('https://evil.com')).toBe('/'); + expect(sanitizeReturnTo('/\\evil.com')).toBe('/'); + expect(sanitizeReturnTo('javascript:alert(1)')).toBe('/'); + expect(sanitizeReturnTo(null)).toBe('/'); + expect(sanitizeReturnTo('')).toBe('/'); + }); +}); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 8266f5e5..41893e40 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -386,3 +386,5 @@ export { fanqieApi, webnovelReportsApi, qimaoApi, zhihuApi } from './api/_mother export { modelsApi } from './api/_models'; export { usersApi } from './api/_users'; export type { UserListItem, UserListResponse, UserUpdatePayload, UserCreatePayload, UserCreateResponse } from '@/types/users'; +export { mcpOAuthApi } from './api/_mcp-oauth'; +export type { McpAuthorizeInfo, McpAuthorizeDecision } from './api/_mcp-oauth'; diff --git a/frontend/src/lib/api/_mcp-oauth.ts b/frontend/src/lib/api/_mcp-oauth.ts new file mode 100644 index 00000000..0f9befea --- /dev/null +++ b/frontend/src/lib/api/_mcp-oauth.ts @@ -0,0 +1,41 @@ +/** + * MCP OAuth 同意页 API(后端 app/api/v1/mcp_oauth.py)。 + * + * 浏览器弹窗登录流程中 /mcp/authorize 同意页的交互面: + * - authorizeInfo 展示待授权客户端(client_name / 回调主机 / scope) + * - approve/deny 用户决定,返回带 code 或 error=access_denied 的回调地址 + * + * 会话通过 HttpOnly cookie(request() credentials: 'include')认证, + * 401 由 _core 的 refresh 机制兜底,仍 401 时页面跳登录。 + */ + +import { request } from './_core'; + +export interface McpAuthorizeInfo { + request_id: string; + client_name: string; + client_uri: string | null; + scopes: string[]; + redirect_host: string; +} + +export interface McpAuthorizeDecision { + redirect_to: string; +} + +export const mcpOAuthApi = { + authorizeInfo: (requestId: string) => + request(`/mcp-oauth/authorize-info?request_id=${encodeURIComponent(requestId)}`), + + approve: (requestId: string) => + request('/mcp-oauth/approve', { + method: 'POST', + body: JSON.stringify({ request_id: requestId }), + }), + + deny: (requestId: string) => + request('/mcp-oauth/deny', { + method: 'POST', + body: JSON.stringify({ request_id: requestId }), + }), +}; diff --git a/frontend/src/lib/navigation.ts b/frontend/src/lib/navigation.ts index a2be1c42..89bd2588 100644 --- a/frontend/src/lib/navigation.ts +++ b/frontend/src/lib/navigation.ts @@ -108,9 +108,20 @@ const EXTRA_ADMIN_ONLY_PATHS = [ '/admin/users', ]; -// 显式声明的公开路径(未登录可访问)。OAuth 回调页必须在此列, -// 否则未登录态进来会被路由守卫踢去 /login,丢失 URL fragment 里的 token。 -const PUBLIC_PATHS = ['/login', '/oauth/callback']; +// 显式声明的公开路径(未登录可访问)。OAuth 回调页与 MCP 授权同意页 +// 必须在此列:前者被守卫拦截会丢 URL fragment 里的 token,后者未登录 +// 进来应由页面自己带 returnTo 跳登录(守卫不知道如何保住 query 参数)。 +const PUBLIC_PATHS = ['/login', '/oauth/callback', '/mcp/authorize']; + +/** + * 净化登录后的回跳地址:只接受站内路径(以单个 / 开头), + * 拒绝协议相对地址(//evil.com)与反斜杠变体,防开放重定向。 + */ +export function sanitizeReturnTo(value: string | null | undefined): string { + if (!value) return '/'; + if (!value.startsWith('/') || value.startsWith('//') || value.includes('\\')) return '/'; + return value; +} function uniquePaths(paths: string[]): string[] { return Array.from(new Set(paths));