Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/19489-oauth-private-host-transport-rule.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
"@objectstack/plugin-auth": minor
---
Expand All @@ -11,6 +11,6 @@
- **⛔ 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.
- **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.
- **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.
- **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.
- **One loud startup line on every plain-HTTP deployment** — the rule's verdict decides WHICH sentence, not whether one is emitted. On an origin it ACCEPTS: `OAuth is served UNENCRYPTED`, followed by the issuer URL and what crosses the wire in the clear. On a PUBLIC plain-HTTP origin, whose OAuth track this same rule leaves dark: the separate line `OAuth discovery is served over PUBLIC plain HTTP`, naming the `.well-known` authorization-server documents this deployment publishes over an unencrypted public origin, stating that the MCP OAuth track is disabled and that TLS is the remedy. The accepted-transport sentence is never printed there — it would assert a transport this deployment was refused. Neither line under TLS. The maintainer worded the notice 「OAuth 未加密:仅限可信内网」; that sentence is carried as the line's meaning and kept verbatim in the code comment beside the call, the emitted strings being English per this repository's convention.

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.
18 changes: 18 additions & 0 deletions .changeset/19571-plaintext-oauth-public-host-notice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@objectstack/plugin-auth": patch
---

A deployment that serves its OAuth authorization-server discovery documents over **public plain HTTP** now says so at startup. Previously it was the quietest configuration on the box: the `.well-known` discovery routes go up regardless of transport, while the only line that mentioned the refused transport sat inside the MCP-surface condition — so a public plain-HTTP boot with `OS_MCP_SERVER_ENABLED=false` emitted no warning at all.

Eligibility now decides **which** sentence is emitted, never **whether** one is:

- an origin the transport rule ACCEPTS (loopback / private / link-local) keeps its line, `OAuth is served UNENCRYPTED`;
- an origin it REFUSES (a public host) gets a new, distinct line, `OAuth discovery is served over PUBLIC plain HTTP`, naming the issuer and the discovery documents, stating that the MCP OAuth track is disabled, and pointing at TLS as the remedy.

Both are emitted once, at mount; neither under TLS. ⛔ No configuration key and ⛔ no environment variable gates either line.

**No admission decision changes.** `isOAuthEligibleBaseUrl` and every transport-rule predicate are untouched, the discovery routes are mounted exactly where and when they were before, no export is added or removed, and no published payload gains a key. The change is two log lines and a comment.

The startup line is also now written in English, this repository's convention for code artefacts. The maintainer's wording 「OAuth 未加密:仅限可信内网」 is carried as the sentence's meaning and kept verbatim in the code comment beside the call.

Clause-②: no
26 changes: 16 additions & 10 deletions docs/qa/platform-checklist/areas/ai.json
Original file line number Diff line number Diff line change
Expand Up @@ -672,10 +672,10 @@
},
{
"id": "ai.mcp-oauth-private-host-transport",
"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",
"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 accepted-transport warning; a public plain-HTTP one is still refused, fail-closed, and logs its own public-plaintext warning",
"since": "v17",
"status": "active",
"revision": 1,
"revision": 2,
"priority": "P1",
"surface": "api",
"fixtures": {
Expand All @@ -694,13 +694,13 @@
"a non-IP intranet hostname (crm.corp, host.docker.internal) — a name, not an address: refused over plain HTTP by the pinned reading"
],
"steps": [
"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",
"boot (a) on a private address over plain http; capture the boot log and record whether the accepted-transport warning `OAuth is served UNENCRYPTED` appears, how many times, and whether it names the issuer URL",
"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",
"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",
"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",
"boot (b) on a PUBLIC host spelling over plain http; capture the boot log and GET both protected-resource paths; record status and body",
"boot (b) on a PUBLIC host spelling over plain http; capture the boot log and GET both protected-resource paths; record status and body — and record separately whether the public-plaintext warning `OAuth discovery is served over PUBLIC plain HTTP` appears and how many times",
"boot (c) on loopback; capture the boot log and the same two GETs",
"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"
"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 BOTH plain-HTTP warnings"
],
"acceptance": [
{
Expand All @@ -712,13 +712,13 @@
{
"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",
"oracle": "api",
"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",
"verify": "404 on both paths + a mount-time warning naming the transport rule — `OAuth discovery is served over PUBLIC plain HTTP` unconditionally, and the `MCP server is enabled but the OAuth track is NOT live` line in addition when the MCP surface is on; confirm the ACCEPTED-transport warning of the clause below is NOT what was read instead — the three lines are different sentences",
"evidence": "the two 404s and the boot-log line"
},
{
"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",
"clause": "eligibility decides WHICH plain-HTTP warning is emitted, never WHETHER one is: every plain-HTTP boot carries exactly one of the two, once, at mount. On a boot whose origin the transport rule ACCEPTS (a and c) it is the accepted-transport line `OAuth is served UNENCRYPTED`; on the PUBLIC plain-HTTP boot (b), whose OAuth track the same rule left dark, it is the distinct line `OAuth discovery is served over PUBLIC plain HTTP`, which states that the AS discovery surface is served over public plain HTTP, that the MCP OAuth track is disabled, and that TLS is the remedy. Neither line on the https re-boot. Each names the issuer URL. The ruled sentence 「OAuth 未加密:仅限可信内网」 is carried as the line's MEANING and lives verbatim in the code comment beside the call — ruling batch #210 item 5",
"oracle": "log",
"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",
"verify": "grep the captured boot logs for the two ENGLISH anchors separately — `OAuth is served UNENCRYPTED`: exactly 1 on (a), exactly 1 on (c), 0 on (b), 0 on the https re-boot; `OAuth discovery is served over PUBLIC plain HTTP`: exactly 1 on (b), 0 on (a), 0 on (c), 0 on the https re-boot. Each matched line names the issuer, the origin followed by /api/v1/auth. ⛔ Grepping for 「OAuth 未加密:仅限可信内网」 now returns 0 on every boot: ruling batch #210 item 5 moved that sentence out of the emitted string into the code comment, so a run still greping it scores every boot as a miss",
"evidence": "the matching log lines with their counts, and the two empty results (the public boot and the https re-boot)"
},
{
Expand All @@ -737,7 +737,7 @@
"negative": [
"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",
"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",
"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",
"conflating any two of the three mount-time lines: `OAuth is served UNENCRYPTED` (boot a and c only), `OAuth discovery is served over PUBLIC plain HTTP` (boot b only) and `MCP server is enabled but the OAuth track is NOT live` (only where the MCP surface is on). They are different sentences with different meanings, and a run that reads the accepted-transport line on boot (b) scores that boot as if it had served OAuth",
"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"
],
"traps": [
Expand All @@ -752,7 +752,7 @@
"source": [
"packages/plugins/plugin-auth/src/auth-manager.ts#isOAuthEligibleBaseUrl (the transport rule, its ruled allow-list and the pinned hostname reading)",
"packages/plugins/plugin-auth/src/auth-manager.ts#isMcpOAuthEnabled (AS enabled AND transport rule satisfied)",
"packages/plugins/plugin-auth/src/auth-plugin.ts (the discovery mount: the unencrypted-transport warning and the 'OAuth track is NOT live' branch)",
"packages/plugins/plugin-auth/src/auth-plugin.ts (the discovery mount: the accepted-transport warning, its public-plaintext sibling branch, and the 'OAuth track is NOT live' branch inside the MCP-surface condition)",
"packages/plugins/plugin-auth/src/mcp-oauth-plaintext-notice.test.ts (the warning's fire-once / not-under-TLS pins)",
"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)"
],
Expand All @@ -762,6 +762,12 @@
"date": "2026-09-21",
"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",
"ref": "claude/issue-19489-oauth-private-host-transport-rule"
},
{
"revision": 2,
"date": "2026-09-22",
"change": "ruling batch #210 item 5 (B/B). D1: eligibility decides WHICH plain-HTTP warning is emitted, never WHETHER one is — boot (b) moves from asserting 0 warnings to asserting exactly 1 occurrence of a new, distinct public-plaintext line, because the .well-known discovery routes are mounted regardless of transport while the 'OAuth track is NOT live' line sits inside the MCP-surface condition, so a public plain-HTTP boot with that surface off used to emit nothing at all. D2: the emitted strings are English and the grep anchors move to them; the ruled Chinese sentence is carried as the line's meaning in the code comment beside the call. Steps, clauses and verifies moved together",
"ref": "claude/issue-19571-plaintext-as-warning"
}
]
}
Expand Down
Loading
Loading