Skip to content

Commit 2aac821

Browse files
huangyiireneclaudejinyitao123
authored
feat(plugin-auth): MCP OAuth over plain HTTP on a private / link-local deployment host (#19534)
Fixes #19489 Clause-②: no MCP OAuth becomes eligible over plain HTTP when the deployment's own canonical origin is **loopback or a private / link-local address** — RFC 1918 `10/8`, `172.16/12`, `192.168/16`; RFC 4193 `fc00::/7`; `169.254/16`, `fe80::/10`. A **public** host keeps TLS-required and fail-closed, exactly as today. Shape ruled by the maintainer, 2026-09-21, verbatim: 「要(开发模式)」 and 「19342 同意兼容」 — a deployment-level transport rule with the semantics of Keycloak's `sslRequired=external`. No configuration key, no environment variable, and no separate dev-mode branch point (a dev bind on a LAN address is a private address). Landed at `91e0ec6e0`, which every reading below was taken against. ## The two readings the card asked for first ### 1. Do the mainstream MCP clients complete OAuth against an `http://` server at all? **Yes — nothing in the reference client stack refuses the scheme.** This compatibility is not bought for nothing. - **The spec text says the opposite of what the implementations do.** The MCP authorization specification (2025-06-18) carries exactly two transport sentences: "All authorization server endpoints **MUST** be served over HTTPS" and "All redirect URIs **MUST** be either `localhost` or use HTTPS." - **The reference TypeScript SDK enforces neither of them on the server's scheme.** Measured in this checkout, against the version this repository installs — `@modelcontextprotocol/sdk` **1.30.0**, at `node_modules/.pnpm/@modelcontextprotocol+sdk@1.30.0_zod@4.6.1/node_modules/@modelcontextprotocol/sdk`. Its whole client OAuth implementation (`dist/esm/client/auth.js`) contains **one** `https:` comparison, inside `isHttpsUrl` at line 322, and that predicate has exactly one caller: line 233, validating a SEP-991 URL-based `client_id`. The server URL, the authorization-server metadata URL, the protected-resource metadata URL, the authorization endpoint and the token endpoint are never compared against a scheme, in `client/auth.js` or in `shared/auth-utils.js`. - **The redirect-URI clause is satisfied regardless of the server's scheme**, because the client's redirect URI is its own loopback callback, not the server's origin. - **Honest limit on this reading.** It is a source reading of the reference SDK at one pinned version, plus the spec text — **not** a driven run of the Claude Desktop, Cursor or Claude Code binaries, none of which is installed in this container and none of which is open source. What it establishes is that the standard client path contains no scheme refusal to trip over; a product shipping its own extra refusal on top would not be visible from here. The checklist item added by this PR carries a step that records the answer from a real client when a run has two machines. ### 2. The hostname case — which reading is pinned, and why **The rule judges the HOST LITERAL of the deployment's own canonical origin.** An intranet hostname is not an IP literal, so `http://crm.corp` and `http://host.docker.internal` stay **refused**; the remedy is to configure the base URL on the private address the deployment already binds, e.g. `http://192.168.1.10:3000`. This is stated in the function's docblock and pinned by tests, not left silent. Both alternatives the card named were considered and rejected, on the record: - **Resolving the hostname in DNS** turns a pure synchronous predicate into a network round trip on every `isMcpOAuthEnabled()` call — including the per-request bearer path — and a name whose answer can change, or be rebound, makes the transport verdict something no test can pin. - **Judging the requester's peer address**, which is what Keycloak does, cannot decide what a deployment **advertises** at mount time: the protected-resource document either exists or does not. It also carries a false-safe that would be adopted knowingly — a plain-HTTP reverse proxy on a public address forwards every request from a private peer, so every requester looks internal and plaintext OAuth is served to the public internet. - **The same false-safe reaches the chosen reading from the other side**: a deployment whose canonical origin is private but which a public plain-HTTP proxy fronts is eligible here. It is **documented rather than keyed**, exactly as Keycloak documents its own — a configuration key for it would be reachable on a public host, which is the deployment this rule exists to refuse. ## Control legs — every one of them, with its reading All from `pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 src/auth-manager.mcp-oauth.test.ts` at `91e0ec6e0`: **65 passed, 0 failed**. **1. Positive — private / link-local over plain HTTP is eligible** | origin | verdict | |---|---| | `http://10.0.0.5:3000` | eligible | | `http://192.168.1.10` | eligible | | `http://172.16.0.1` | eligible | | `http://172.31.255.255` | eligible | | `http://[fc00::1]` | eligible | | `http://169.254.1.1` | eligible | | `http://[fe80::1]` | eligible | **2. Negative — the security floor. A public host over plain HTTP is still refused** | origin | verdict | |---|---| | `http://example.com` | refused | | `http://203.0.113.5` | refused | | `http://intranet.corp:3000` | refused | | `http://host.docker.internal:3000` | refused | Carried through the manager as well, not only the predicate: `new AuthManager({ baseUrl: 'http://example.com' })` answers `isMcpOAuthEnabled() === false` and `getMcpResourceMetadataUrl() === null` — nothing advertised, fail-closed. **3. Invariants — untouched** `https://` on any host (`acme.example.com`, `intranet.corp`, `203.0.113.5`) is eligible. `ftp://localhost` and `ws://10.0.0.5` are false; a non-URL is false. Loopback in every form is eligible: `localhost`, `127.0.0.1`, `[::1]`, `*.localhost` — and `127.0.0.2`, since 127.0.0.0/8 is loopback and leaving it refused beside an eligible `10.0.0.5` would be incoherent. **4. Boundary traps — each one refused in the unsafe direction** | origin | verdict | why | |---|---|---| | `http://172.15.0.1` | refused | outside RFC 1918's 172.16.0.0/12 | | `http://172.32.0.1` | refused | outside it on the other side | | `http://10.0.0.5.evil.com` | refused | a hostname that merely begins with a private IPv4 string | | `http://192.168.1.10.attacker.test` | refused | the same shape, other range | | `http://[fec0::1]` | refused | site-local, deprecated by RFC 3879, outside `fc00::/7` | | `http://[febf::1]` | eligible | the last `fe80::/10` prefix — the inclusive edge | | `http://[fdff:ffff::1]` | eligible | the last `fc00::/7` prefix — the inclusive edge | | `http://[::ffff:10.0.0.5]` | refused | IPv4-mapped IPv6, refused rather than unwrapped | | `http://0.0.0.0:3000` | refused | wildcard bind, not a reachable origin | | `http://[::]:3000` | refused | the same | | `http://167772161` | eligible | WHATWG URL canonicalises this to hostname `10.0.0.1` — it **is** that address | The refusals are IP-literal parses, not text prefixes: the IPv4 branch is a both-ends-anchored four-octet match, and the IPv6 branch expands `::` elision to eight groups and masks bits (`first & 0xfe00 === 0xfc00`, `first & 0xffc0 === 0xfe80`). **5. The warning fires once, on plaintext only** `packages/plugins/plugin-auth/src/mcp-oauth-plaintext-notice.test.ts` — **6 passed**. It drives the discovery mount and counts occurrences of the ruled wording 「OAuth 未加密:仅限可信内网」 in the captured log: - `http://192.168.1.10:3000` — **1** occurrence, naming the issuer `http://192.168.1.10:3000/api/v1/auth` - `http://localhost:3000` — **1** occurrence (loopback is plain HTTP too; the ruling exempts only TLS) - `https://acme.example.com` — **0** occurrences - `http://10.0.0.5:3000` — **1** occurrence while the same call mounts several routes, so route count cannot move notice count - emitted at `warn`, never at `info` - exporting `OS_ALLOW_INSECURE_OAUTH_HTTP=true` changes none of it **No key and no variable exists.** `isOAuthEligibleBaseUrl` takes one argument — the deployment's own origin — and reads no process state; the test asserts its arity and re-runs the public refusals with `OS_ALLOW_INSECURE_OAUTH_HTTP=true` exported. `git grep OS_ALLOW_INSECURE_OAUTH_HTTP` finds it in test setup only. ## Reverse verification — three ablations, each landed on disk and restored byte-identical Run through `node scripts/ablation-replace.mjs` from the committed state, so each mutation's landing and each restore is the tool's verdict rather than a claim. Predicted direction for all three: **turn red**. Observed: turn red, in the predicted legs. | mutation | blob move | result | restore | |---|---|---|---| | unanchor the IPv4 literal regex (drop the trailing `$`) | `171d493d987a` to `833fb090cb6d` | **2 failed** — `10.0.0.5.evil.com` and `192.168.1.10.attacker.test` both became eligible | blob back to `171d493d987a`, `git diff HEAD` empty | | `return isPrivateOrLoopbackHostLiteral(host)` becomes `return true` | `171d493d987a` to `9ed3b10ffe2d` | **15 failed** — all four public-arm legs, eight boundary legs, the no-key leg, and both manager-level legs | blob back to `171d493d987a`, `git diff HEAD` empty | | disable the plaintext notice at its `if` | `0c0ec2f98d6c` to `3e57b0cbb649` | **5 of 6 failed** — every leg but the TLS one, which is the leg that asserts absence | blob back to `0c0ec2f98d6c`, `git diff HEAD` empty | Resolution note: both test files import the subject by relative source path, so the subject is `src/`, not `dist/` — no rebuild step stands between a mutation and the run it is measured by. ## Verification - `pnpm --filter '@objectstack/plugin-auth^...' build --concurrency=2` — exit 0 (under `scripts/pm/os-verify-lock.sh`, `VERDICT command-exit 0`) - `pnpm --filter @objectstack/plugin-auth test` — **114 files / 2421 tests passed**, exit 0 (locked, `VERDICT command-exit 0`) - `pnpm --filter @objectstack/plugin-auth build && pnpm --filter @objectstack/plugin-auth typecheck` — exit 0 (locked). `check:test-typecheck` held its shrink-only ledger at 10 files / 94 errors / 23 pinned signatures - `pnpm lint` — the repo-wide `eslint . --no-inline-config`, exit 0. Not a narrowed run: the whole governed population was linted, so no narrowing needs proving - `pnpm check:platform-checklist` — exit 0; 15 areas, **265** items, 619/637 symbol anchors resolved - **Derived gate families**: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived **66**; all 66 were run and reconciled with their exit codes through `--ran`. **64 exit 0.** The remaining two are NOT MEASURED, not red — both exit **3**, `PREREQUISITE NOT MET`: - `pnpm check:dual-build-cjs-loads` — needs a whole-workspace `dist/`; it names 40 unbuilt packages, none of them in this diff. Covered by CI's `Build Core`. - `pnpm check:type-check-debt` — refuses to re-measure without the built dependency closure, on purpose. Covered by CI's `TypeScript Type Check`, which builds the closure first. Neither can be moved by this diff: it adds no package, no export and no entry point, and touches source in one already-built package whose own typecheck is green above. ## Acceptance notes - **A mixed-language log line, declared rather than slipped in.** The ruling fixes the warning's wording verbatim as 「OAuth 未加密:仅限可信内网」; `AGENTS.md` says repository artifacts keep their existing language convention, and `git grep` finds no CJK in any package source today outside `translations/`. The line therefore leads with the ruled clause verbatim and continues in English. Flagging it so a reviewer can rule the other way — the alternative was to keep the ruled phrase only in a code comment, which would leave nothing in the log for the checklist item's `grep` to score. - **127.0.0.0/8 is now recognised as loopback**, where the old rule matched only the exact string `127.0.0.1`. Strictly inside "loopback", which the ruling's first arm names, and refusing `127.0.0.2` beside an eligible `10.0.0.5` would have been incoherent. Noted rather than assumed. - **The contributor's strict-spelling refusal pin was kept** as the production arm's, with its title corrected: `http://intranet.corp:3000` is refused because it is a hostname rather than an IP literal, which the old title's "TLS rule" no longer describes. - **Noted, not filed — the mount-time OAuth warning is not reachable by any test in this package other than the one added here.** `registerOidcDiscoveryRoutes` is private, async, and dynamically imports the provider, so the new test drives it through a cast. Carrier: none — no queued card and no PR touches this seam, and it is an observation about test ergonomics rather than a defect, a contract violation or an authoring trap. ## 维护者速读(草稿) **改了什么** —— MCP 的 OAuth 以前只有 TLS 部署(外加 loopback)能用;现在**内网部署也能用**:部署自己的地址是 `10.x` / `192.168.x` / `172.16-31.x` / `169.254.x` / IPv6 的 `fc00::/7` / `fe80::/10` 时,走明文 HTTP 也照常提供 OAuth。**公网地址一个字节没动**,仍然拒绝。 **为什么改** —— 一个已经用明文 HTTP 提供登录页和会话 cookie 的部署,OAuth 再拒绝明文并不多保护什么,只是让这个部署用不上 MCP。开发人员在局域网上跑 `os dev` 是同一件事,所以没有单独的"开发模式"开关。 **风险与代价(含回滚)** —— 风险集中在一处:私网判断写错就等于把明文 OAuth 开给公网。为此判断读的是 IP 字面量而不是文本前缀,`172.15.x`、`10.0.0.5.evil.com`、`fec0::1` 这些"看着像内网"的全部拒绝,并且用三次消融证明这些断言真的会失败。**无配置键、无环境变量**,所以没有"被误开"的面。回滚 = revert 本 PR 一个提交,`isOAuthEligibleBaseUrl` 回到只认 loopback,无数据、无迁移、无发布面残留。内网主机名(`crm.corp`)仍然拒绝,改法是把 baseUrl 写成内网 IP。 **席位意见** —— (留空,待席位定稿) **你要做的** —— 确认两点:① 明文告警里保留中文原话是否可接受(全仓代码目前无中文);② 内网**主机名**继续拒绝、只认 IP 字面量,是否就是你要的边界。 --- _Generated by [Claude Code](https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF)_ --- ## ⚠️ 正文订正 —— 以 final head `18f44168d1` 为准(本节由认领席 `domain:services` 补写) dev 正文只写一次、⛔ 不 PATCH,而本 PR 走了**三轮**。以上正文写于第一轮 head `91e0ec6e0`,其中若干陈述已过期。**冲突处以本节为准。** head 轨迹:`91e0ec6e0` → `e7c4df5a34` → **`18f44168d1`**。 ### 1. 🔴 `127.0.0.0/8` —— 正文当时称已钉,**当时并没有**;现在有了 第一轮正文在 Invariants 段写「and `127.0.0.2`, since 127.0.0.0/8 is loopback」。隔离达档复核实测:把 `127.0.0.0/8` 改回 `127.0.0.1/32` 的消融当时**失败 0 条** ⇒ 树上**没有**这条断言。**行为一直是对的,声明的钉子不存在。** 现已补钉(第二轮):`127.0.0.2` / `127.255.255.254` eligible,`126.0.0.1` / `128.0.0.1` refused;同一消融现在**失败 2 条**。 ### 2. 日志措辞:提出 → 被席位误裁 → 撤回 → 按裁定原文落地 第一轮正文把「中文原话留日志 vs 只留注释」挂出来**请维护者裁**,这是对的。认领席一度判了后者并已**撤回**(更正 `5757931777`)——**那是重裁,不是裁量**:裁定第 ④ 条逐字含「(「OAuth 未加密:仅限可信内网」)」,验收段写明日志要 **carries** 它。 最终实况(运行时实测,⛔ 非读源码):发出的字符串以裁定原话**开头**,英文操作性说明接在同一行后半。checklist 锚点随之归位。 ⚠️ 与 `AGENTS.md:31`(仓库产物以英文为惯例)的张力**未在本 PR 解决**,已另立决策卡 **#19571** 交维护者。 ### 3. 告警条件:补了资格判断,并因此新增了公网腿 `auth-plugin.ts` 的条件由「只看 scheme」改为「明文 **且** eligible」。修的是一句**假陈述**:公网明文 boot 上原条件会打印「This is accepted only because the host is loopback or a private / link-local address」,而该部署恰恰**未被接受**(复核用真源码驱动复现,`PROBE-PUBLIC-NOTICE-COUNT=1`)。 告警腿现为 **1 / 1 / 0 / 1 + 公网 0**:私网、loopback 各 1;TLS 0;多路由挂载仍 1;**三个公网明文 origin(`example.com`、`203.0.113.5`、`intranet.corp:3000`)各 0** —— 这一面此前**零覆盖**(文件里每个 origin 都是 eligible 的)。 ### 4. 边界表新增五行 —— 闭一个**既存**覆盖缺口 `10/8`、`192.168/16`、`169.254/16` 三个块此前**没有任何相邻块拒绝腿**:把 `10.0.0.0/8` 放宽到 `/6`(静默放开 `8.8.8.8`)、`192.168.0.0/16` 放宽到 `/8`(放开整个 `192.0.0.0/8`)等变异,**失败断言均为 0**。 ⚠️ 这**不是** CIDR 重构引入的 —— 复核把同样变异打在重构前的算术实现上,同样零红。但重构把范围语义搬进了一张**一个字符就能改**的表,误改门槛下降。 现补:`11.0.0.1` · `192.167.0.1` · `192.169.0.1` · `169.253.0.1` · `169.255.0.1` 全 refused。对应五个消融各自变红(1/2/1/2/1)。 ### 5. IPv4 解析器去重(第二轮,复核的非阻塞质量注) 删 `parseIpv4Literal`,IPv4 臂改走同文件 ADR-0069 D5 的 `ipv4ToInt` / `ipMatchesRange` + 具名 CIDR 表。 🔴 **这次重构让第一轮的复核结论失效过一次**,因为被扫的正是这条路径。已**重扫**:接受面 **83/83 逐行不变(CHANGED=0)**;`ipMatchesRange` 对 **393,216** 个地址与独立算术参考谓词对拍 **0 mismatch**;IPv6 臂 **131,092** 个字面量对拍 **0 差异**;`ipv4ToInt` 新增的 `.trim()` 实测在 `u.hostname` 取值域里**不可达**,且即便可达也只归一化、不放宽。 **final head `18f44168d1` 的 `auth-manager.ts` 与被重扫的 `e7c4df5a34` 逐字节相同**(认领席现验)⇒ 重扫结论适用于将要落地的实现。 ### 6. 反向验证:三轮共 **11** 次消融(3 + 3 + 5) 正文原三行表已被取代。每次消融均自 committed 状态落盘、按 blob 核实落地与还原、`git diff HEAD` 为空。 ### 7. ⛔ 刻意未做 公网明文部署仍挂 3 条 AS 发现路由(`main` 上的既有形态,本 PR 未改),且除 `OAuth track is NOT live` 外**没有**一行说「AS 正在公网明文提供」。裁定第 ④ 条按字面读是否仍欠它一行,是**裁量分叉** ⇒ 已进决策卡 **#19571**,⛔ 不在本 PR 自裁。 <sub>本节由 `domain:services` 席(`session_01AhQASwqJr2Z7XfGWUdvnbF`)补写,依 `.claude/agents/os-dev.md`「PR 正文 dev 只写一次…事后要改的报告点名改法,席位代写」。</sub> --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: jinyitao123 <jinyitao123@users.noreply.github.com>
1 parent 2306a75 commit 2aac821

6 files changed

Lines changed: 600 additions & 22 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/plugin-auth": minor
3+
---
4+
5+
MCP OAuth is eligible over plain HTTP when the deployment's own host is loopback **or a private / link-local address** — RFC 1918 `10/8`, `172.16/12`, `192.168/16`; RFC 4193 `fc00::/7`; `169.254/16`, `fe80::/10`. A **public** host keeps TLS-required and fail-closed, exactly as before (#19489).
6+
7+
Clause-②: no
8+
9+
Shape ruled by the maintainer (2026-09-21), with the semantics of Keycloak's `sslRequired=external`: 「要(开发模式)」 and 「19342 同意兼容」. Reasoning of record — a deployment that already serves its login form and session cookies over plain HTTP gains nothing from OAuth refusing plain HTTP; the refusal only removes MCP from that deployment. An intranet install and a developer's `os dev` bound to a LAN address are the same case, so development mode is **subsumed** and there is no separate dev-mode branch.
10+
11+
- **⛔ No configuration key and ⛔ no environment variable.** A switch would be reachable on a public host, which is exactly the deployment this rule must keep refusing. `OS_ALLOW_INSECURE_OAUTH_HTTP` is not introduced in any form.
12+
- **The public arm is unchanged.** `http://example.com` and `http://203.0.113.5` are refused as before; the MCP endpoint stays API-key-only and no OAuth metadata is advertised. Addresses that merely look private are refused with it: `172.15.x` / `172.32.x` fall outside RFC 1918, `fec0::/10` site-local (RFC 3879) falls outside `fc00::/7`, and a hostname that only begins with a private IPv4 string (`10.0.0.5.evil.com`) is a name, not an address.
13+
- **A non-IP hostname over plain HTTP stays refused**, deliberately: the rule judges the host **literal** of the deployment's own canonical origin. `crm.corp` and `host.docker.internal` are not IP literals, so configure the base URL on the private address the deployment already binds (`http://192.168.1.10:3000`). Resolving the name in DNS was rejected — it makes a synchronous predicate depend on a round trip whose answer can be rebound — and judging the requester's peer address, which is what Keycloak does, was rejected because it cannot decide what a deployment **advertises** at mount time and because a plain-HTTP reverse proxy on a public address makes every requester look internal.
14+
- **One loud startup line** on every plain-HTTP deployment the rule ACCEPTS: 「OAuth 未加密:仅限可信内网」, followed by the issuer URL and what crosses the wire in the clear. None under TLS — and none on a public plain-HTTP deployment either, whose OAuth track this same rule leaves dark: that line would assert a transport this deployment was refused, so its line stays the separate `OAuth track is NOT live` warning.
15+
16+
Nothing an author writes changes: `isOAuthEligibleBaseUrl` keeps its signature, no export is added or removed, and no published payload gains a key. A deployment on a public host sees no behaviour change at all.

‎docs/qa/platform-checklist/areas/ai.json‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,101 @@
669669
"history": [
670670
{ "revision": 1, "date": "2026-08-30", "change": "new item (sweep 2026-08-30, cross-angle hit 1+4): the console-side AI gating had no coverage — the catalog-signal design (useAiSurfaceEnabled) hides FAB/dock/⌘⇧I/header/Home affordances and no-flash-redirects /ai on an agent-less boot, while SystemHubPage's 'AI Approvals' card is built ungated and its page polls the dead /ai/pending-actions endpoint every 5s (501) rendering a fake empty queue beside the error alert. Authored as a NEW browser sibling of ai.open-edition-honest-degradation (which keeps the API half) rather than extending it, so neither surface double-covers the other; the two K2 defect clauses are expected-fail probes, the defect row itself is the sweep's FOLLOW-UPS entry", "ref": "#sweep-2026-08-30" }
671671
]
672+
},
673+
{
674+
"id": "ai.mcp-oauth-private-host-transport",
675+
"title": "the MCP OAuth transport rule is judged on the DEPLOYMENT's own host: a private / link-local plain-HTTP deployment serves the OAuth track and logs the unencrypted-transport warning; a public plain-HTTP one is still refused, fail-closed",
676+
"since": "v17",
677+
"status": "active",
678+
"revision": 1,
679+
"priority": "P1",
680+
"surface": "api",
681+
"fixtures": {
682+
"app": "any",
683+
"requires": [
684+
"THREE boots of the same build, differing only in the deployment base URL: (a) a PRIVATE address the host actually holds — a LAN address under `os dev`, e.g. http://192.168.x.x:PORT or http://10.x.x.x:PORT; (b) a PUBLIC host spelling — http://example.com or an http:// public IP literal, which needs no reachability because every clause here is about what the deployment ADVERTISES; (c) loopback (http://localhost:PORT), the unchanged control",
685+
"the MCP HTTP surface at its default (on — served unless OS_MCP_SERVER_ENABLED=false) and the embedded AS at its default (follows the MCP surface — packages/plugins/plugin-auth/src/auth-manager.ts#resolveOidcProviderEnabled)",
686+
"the boot log of each boot, captured from the first line — the transport warning is emitted ONCE at discovery-route mount and is not repeated per request",
687+
"a second machine (or container) on the same private network for the (a) boot, if the run also drives a real MCP client end to end"
688+
]
689+
},
690+
"variants": [
691+
"IPv4 private: 10.0.0.0/8 · 172.16.0.0/12 · 192.168.0.0/16 — and the RFC 1918 edges 172.15.x / 172.32.x, which are PUBLIC space",
692+
"link-local: 169.254.0.0/16 · fe80::/10",
693+
"IPv6 unique-local fc00::/7 — and fec0::/10 site-local, deprecated by RFC 3879 and OUTSIDE it",
694+
"a non-IP intranet hostname (crm.corp, host.docker.internal) — a name, not an address: refused over plain HTTP by the pinned reading"
695+
],
696+
"steps": [
697+
"boot (a) on a private address over plain http; capture the boot log and record whether the unencrypted-transport warning 「OAuth 未加密:仅限可信内网」 appears, how many times, and whether it names the issuer URL",
698+
"on boot (a), GET /.well-known/oauth-protected-resource and its path-inserted variant /.well-known/oauth-protected-resource/api/v1/mcp; record status and body",
699+
"on boot (a), POST /api/v1/mcp with NO credentials; record the 401 and its WWW-Authenticate header, including whether a resource_metadata pointer is present",
700+
"if a second machine is available, drive a real MCP client from it against boot (a) over http:// and record whether the client completes the OAuth flow, refuses the scheme up front, or fails elsewhere — RECORD WHICH, because a client-side scheme refusal changes what this compatibility is worth",
701+
"boot (b) on a PUBLIC host spelling over plain http; capture the boot log and GET both protected-resource paths; record status and body",
702+
"boot (c) on loopback; capture the boot log and the same two GETs",
703+
"re-boot (a) with an https base URL (a self-signed terminator is enough — no client needs to trust it, only the deployment's own base URL has to be https); capture the boot log and record the ABSENCE of the warning"
704+
],
705+
"acceptance": [
706+
{
707+
"clause": "boot (a) — private / link-local over plain HTTP — serves the OAuth track: /.well-known/oauth-protected-resource answers 200 with RFC 9728 metadata whose `resource` is this deployment's own <origin>/api/v1/mcp, and the anonymous /api/v1/mcp 401 carries a WWW-Authenticate resource_metadata pointer at it",
708+
"oracle": "api",
709+
"verify": "both discovery responses plus the 401 headers, against packages/plugins/plugin-auth/src/auth-manager.ts#getMcpProtectedResourceMetadata; the advertised resource must be the boot's OWN origin, never localhost",
710+
"evidence": "the two metadata responses and the 401 head"
711+
},
712+
{
713+
"clause": "boot (b) — a PUBLIC host over plain HTTP — is still refused, fail-closed and UNCHANGED: both protected-resource paths 404, nothing OAuth is advertised, and the boot log says the OAuth track is not live and why. ⛔ This clause is the security floor of the item: a 200 here means plaintext OAuth was opened to the public internet, which is worse than the refusal the rule relaxed",
714+
"oracle": "api",
715+
"verify": "404 on both paths + the mount-time warning naming the transport rule (auth-plugin.ts, the `MCP server is enabled but the OAuth track is NOT live` branch); confirm the unencrypted-transport warning of the clause below is NOT what was read instead — the two lines are different sentences",
716+
"evidence": "the two 404s and the boot-log line"
717+
},
718+
{
719+
"clause": "the unencrypted-transport warning carries the ruled wording 「OAuth 未加密:仅限可信内网」 and is emitted exactly once, at mount, on every plain-HTTP boot whose origin the transport rule ACCEPTS (a and c); NOT on the https re-boot, and NOT on the public plain-HTTP boot (b), whose OAuth track is dark — there the line would assert a posture that deployment does not have. It names the issuer URL",
720+
"oracle": "log",
721+
"verify": "grep the captured boot logs for the ruled wording 「OAuth 未加密:仅限可信内网」, which the acceptance requires the startup log to CARRY (the English clause `OAuth is served UNENCRYPTED` follows it on the same line and greps to the same count): exactly 1 occurrence on (a), exactly 1 on (c), 0 on (b), 0 on the https re-boot; the line names the issuer, the origin followed by /api/v1/auth",
722+
"evidence": "the matching log lines with their counts, and the two empty results (the public boot and the https re-boot)"
723+
},
724+
{
725+
"clause": "loopback (c) is unchanged — the OAuth track is live exactly as it was before the rule widened, with the same advertised metadata",
726+
"oracle": "api",
727+
"verify": "boot (c)'s two discovery responses match boot (a)'s shape with the loopback origin substituted",
728+
"evidence": "the loopback metadata response"
729+
},
730+
{
731+
"clause": "no configuration key and no environment variable can move any of the above: re-running boot (b) with OS_ALLOW_INSECURE_OAUTH_HTTP=true (the escape hatch the ruling refused) changes nothing — the public plain-HTTP boot is still refused",
732+
"oracle": "api",
733+
"verify": "boot (b) re-run with that variable exported answers the identical 404s; the variable is read nowhere in the tree (git grep it)",
734+
"evidence": "the re-run 404s and the empty grep"
735+
}
736+
],
737+
"negative": [
738+
"reading a 200 on boot (b) as 'the feature works' is the inverted verdict this item exists to prevent — the public arm answering anything but a refusal is a FAIL, not a pass with a wider surface",
739+
"scoring boot (a) from a localhost request on the server itself: the request would be loopback, which was already eligible before this rule, so the private-address arm was never exercised — drive it from the private address, or at minimum assert the ADVERTISED origin is the private one",
740+
"reading the mount-time 'OAuth track is NOT live' line as the unencrypted-transport warning (or vice versa): they are different sentences with opposite meanings, and a run that conflates them scores boot (b) as if it had served OAuth. Boot (b) carries the first and never the second",
741+
"treating an intranet HOSTNAME boot (http://crm.corp) as the private-address case — it is a name, not an address, and the pinned reading refuses it; a run that uses one has tested the hostname reading, not this clause"
742+
],
743+
"traps": [
744+
"cache-staleness",
745+
"dispatcher-vs-hono-route",
746+
"stale-dist"
747+
],
748+
"automated": {
749+
"kind": "unit",
750+
"ref": "packages/plugins/plugin-auth/src/auth-manager.mcp-oauth.test.ts"
751+
},
752+
"source": [
753+
"packages/plugins/plugin-auth/src/auth-manager.ts#isOAuthEligibleBaseUrl (the transport rule, its ruled allow-list and the pinned hostname reading)",
754+
"packages/plugins/plugin-auth/src/auth-manager.ts#isMcpOAuthEnabled (AS enabled AND transport rule satisfied)",
755+
"packages/plugins/plugin-auth/src/auth-plugin.ts (the discovery mount: the unencrypted-transport warning and the 'OAuth track is NOT live' branch)",
756+
"packages/plugins/plugin-auth/src/mcp-oauth-plaintext-notice.test.ts (the warning's fire-once / not-under-TLS pins)",
757+
"docs/qa/platform-checklist/areas/ai.json — item `ai.mcp-http-surface` (the surface this item shares — that one asserts the endpoint's gates, this one asserts WHICH DEPLOYMENTS get the OAuth track at all)"
758+
],
759+
"history": [
760+
{
761+
"revision": 1,
762+
"date": "2026-09-21",
763+
"change": "initial — authored with the card that widened the transport rule from loopback-only to loopback-or-private, under the maintainer ruling of 2026-09-21. No checklist item asserted MCP OAuth from a non-loopback host at all, so neither the arm that opened nor the public arm that must stay closed had a standing run",
764+
"ref": "claude/issue-19489-oauth-private-host-transport-rule"
765+
}
766+
]
672767
}
673768
]
674769
}

0 commit comments

Comments
 (0)