Repository navigation
Commit 2aac821
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)_
---
## 1 parent 2306a75 commit 2aac821
6 files changed
Lines changed: 600 additions & 22 deletions
File tree
- .changeset
- docs/qa/platform-checklist/areas
- packages/plugins/plugin-auth/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
669 | 669 | | |
670 | 670 | | |
671 | 671 | | |
| 672 | + | |
| 673 | + | |
| 674 | + | |
| 675 | + | |
| 676 | + | |
| 677 | + | |
| 678 | + | |
| 679 | + | |
| 680 | + | |
| 681 | + | |
| 682 | + | |
| 683 | + | |
| 684 | + | |
| 685 | + | |
| 686 | + | |
| 687 | + | |
| 688 | + | |
| 689 | + | |
| 690 | + | |
| 691 | + | |
| 692 | + | |
| 693 | + | |
| 694 | + | |
| 695 | + | |
| 696 | + | |
| 697 | + | |
| 698 | + | |
| 699 | + | |
| 700 | + | |
| 701 | + | |
| 702 | + | |
| 703 | + | |
| 704 | + | |
| 705 | + | |
| 706 | + | |
| 707 | + | |
| 708 | + | |
| 709 | + | |
| 710 | + | |
| 711 | + | |
| 712 | + | |
| 713 | + | |
| 714 | + | |
| 715 | + | |
| 716 | + | |
| 717 | + | |
| 718 | + | |
| 719 | + | |
| 720 | + | |
| 721 | + | |
| 722 | + | |
| 723 | + | |
| 724 | + | |
| 725 | + | |
| 726 | + | |
| 727 | + | |
| 728 | + | |
| 729 | + | |
| 730 | + | |
| 731 | + | |
| 732 | + | |
| 733 | + | |
| 734 | + | |
| 735 | + | |
| 736 | + | |
| 737 | + | |
| 738 | + | |
| 739 | + | |
| 740 | + | |
| 741 | + | |
| 742 | + | |
| 743 | + | |
| 744 | + | |
| 745 | + | |
| 746 | + | |
| 747 | + | |
| 748 | + | |
| 749 | + | |
| 750 | + | |
| 751 | + | |
| 752 | + | |
| 753 | + | |
| 754 | + | |
| 755 | + | |
| 756 | + | |
| 757 | + | |
| 758 | + | |
| 759 | + | |
| 760 | + | |
| 761 | + | |
| 762 | + | |
| 763 | + | |
| 764 | + | |
| 765 | + | |
| 766 | + | |
672 | 767 | | |
673 | 768 | | |
674 | 769 | | |
0 commit comments