Skip to content

fix(plugin-auth): register an MCP client that omits application_type with a loopback or custom-scheme redirect as native - #25156

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-25118-dcr-infer-native
Oct 22, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-25118-dcr-infer-native

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #25118

Clause-②: no (a runtime security-boundary change the maintainer ruled; no published type, export or metadata key moves)
Metadata-protocol: none

What changes

plugin-auth now passes clientRegistrationDefaultApplicationType: 'infer' to @better-auth/oauth-provider 1.7.12. A comment names this card, ruling 6329352251 (letter A1) and the measured boundary. The whole production change is that one option line in the oauthProvider({ ... }) options of packages/plugins/plugin-auth/src/auth-manager.ts. There is no hook on /oauth2/register and no second classification rule (ruling A1, items 1 and 4).

  • Before: a dynamic registration that omitted application_type was filled as web. The web redirect rule refuses a loopback http or custom-scheme redirect, so every local MCP client (Claude Code, Cursor, VS Code, Codex CLI) was refused at registration with invalid_redirect_uri.
  • After: the provider's own classifier treats such a body as native when any redirect URI uses a non-http(s) scheme or http on exactly localhost, 127.0.0.1 or [::1], and it then holds each redirect to the native rule. Every other body stays web. An application_type the client sends is never overridden.
  • Scope: the classifier runs only on the dynamic registration source (registerEndpoint in the installed dist). The Setup form's managed create-client door (/sys-oauth-application/register) is not affected. The option does nothing while DCR is off.
  • No layer in front: plugin-auth puts nothing in front of /oauth2/register. The global before-hook has no branch for that path, and auth-plugin.ts mounts no raw route on it.

The measured boundary, pinned on the real provider

The new file packages/plugins/plugin-auth/src/auth-manager.mcp-oauth-dcr-infer.test.ts boots the real better-auth 1.7.12 and @better-auth/oauth-provider 1.7.12 handler from the exact options AuthManager produces (MCP on, so DCR on), then drives POST /oauth2/register and GET /oauth2/authorize.

Every body omits application_type, as the clients send it. The first cell sends the card's own reproduction body and asserts that it has no application_type key. The existing auth-manager.mcp-oauth-resource.test.ts registers with application_type: native spelled out, which is why it stayed green through this defect.

Cell (no application_type unless stated) Registration Authorize
The card's body: public client, http://localhost redirect 201 native, no secret refused without PKCE, consent with PKCE
Public, http://127.0.0.1 redirect 201 native refused without PKCE, consent with PKCE
Public, custom-scheme (cursor://) redirect 201 native refused without PKCE, consent with PKCE
Public, https://evil.example/cb alone 201 web (unchanged) refused without PKCE, consent with PKCE
Public, http://evil.example/cb 400 invalid_redirect_uri, no client stored n/a
Public, http://localhost.evil.example/cb 400 invalid_redirect_uri, no client stored n/a
Public, loopback, explicit application_type: web 400 invalid_redirect_uri (a sent type is never overridden) n/a
Confidential (client_secret_basic), loopback 201 native, issued a client_secret refused without PKCE, consent with PKCE
Auth method omitted (filled as client_secret_basic), loopback 201 native, issued a client_secret refused without PKCE, consent with PKCE
Confidential (client_secret_basic), custom scheme 201 native, issued a client_secret refused without PKCE, consent with PKCE
Public, loopback plus https://evil.example/cb in one body 201 native (any qualifying redirect classifies the body) refused without PKCE, consent with PKCE
DCR off (OS_OIDC_DCR_ENABLED=false), the card's body 403 access_denied, no client stored n/a
  • Not "public clients only". The confidential rows are stated as they behave. The classifier reads redirect URIs and never token_endpoint_auth_method. The same confidential body with application_type: native spelled out is already accepted on main, so no registrable client shape is added; only the meaning of an omitted field changes (ruling A1, item 3).
  • PKCE. PKCE stays required at authorize for every registered client: always for a public client, and for a confidential DCR client because clientRegistrationRequirePKCE keeps its true default.
  • The PKCE check is a differential. "Refused without PKCE" means a redirect back to the client's own redirect URI with error=invalid_request, a description that names PKCE, and no code. The paired request is identical apart from the S256 PKCE parameters (same client, session, redirect, scopes and resource) and reaches the consent page, so the refusal cannot have another cause.
  • Liveness. The existing test "every option AuthManager passes occurs in the installed provider" (auth-manager.mcp-oauth-resource.test.ts) now also covers the new option name.

Verification

The package readings below were taken at 5a7e1f02a, on the shared container. The head 6d4fd2a5a is that commit merged with origin/main 9cfc57533. The four incoming commits touch cloud-connection, runtime, service-automation, verify, docs and an ADR, and none of those is in plugin-auth's dependency closure. So the package readings stand (the scoped re-check of AGENTS §10), and the gate union was re-run on 6d4fd2a5a. The test imports ./auth-manager from source, so the ablation needed no rebuild to reach the run.

  • New file: pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 src/auth-manager.mcp-oauth-dcr-infer.test.ts gave Test Files 1 passed (1), Tests 12 passed (12), lock VERDICT command-exit 0.
  • Ablation (one-time, not committed): scripts/ablation-replace.mjs deleted the option line. Proof that it landed: anchor count 1 then 0, blob 9c00cadb then a4a53154. Result: Tests 7 failed | 5 passed (12), lock VERDICT command-exit 1.
    • Red, as predicted: all seven native cells (the card's body, 127.0.0.1, custom scheme, the three confidential cells, the mixed body). Each got 400 invalid_redirect_uri: web clients require https redirect URIs on non-loopback hosts, the production symptom.
    • Green, as predicted: the five cells that do not depend on the option (https alone stays web; the two foreign http hosts and explicit web stay refused; DCR off is 403).
    • Restore proven: blob equals HEAD 9c00cadb, git diff HEAD empty, git status --porcelain empty.
  • Package suite: pnpm --filter @objectstack/plugin-auth test gave Test Files 182 passed (182), Tests 3450 passed | 10 skipped (3460), lock VERDICT command-exit 0.
  • Typecheck: pnpm --filter @objectstack/plugin-auth typecheck exited 0. That covers the source, the examples, and the test layer under tsconfig.test.json (debt ledger held, no growth).
  • Build and published surface: pnpm --filter @objectstack/plugin-auth build exited 0. The built dist/*.d.ts has 0 hits for clientRegistrationDefaultApplicationType, while dist/index.mjs has 1 (positive control), so no published type moves.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 70 commands at 6d4fd2a5a (merge base 9cfc57533). All 70 ran and exited 0, with each code captured before any pipe. The --ran reconciliation reads "70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN". The rest of the farm and the type-check lanes run in CI.

Acceptance owed after this lands

Ruling 6329352251 item 5 (carried from 6327761812) requires a browser walk on a deployed environment after the cloud pin moves: claude mcp add --transport http NAME https://ENV-HOST/api/v1/mcp, then /mcp, then Authenticate, completing in the browser and listing the tools. That walk makes the environment's Setup, Integrations, Connect an Agent page true. Not measured here: it needs a deployed environment.

Acceptance notes

  • The comment above rawApp.post(... /sys-oauth-application/register ...) in auth-plugin.ts still says DCR on /oauth2/register is something "we don't want to enable broadly". DCR has been on by default whenever the MCP server is on (resolveDcrEnabled), so that sentence is stale. It is noted only: it is outside this card's file surface and changes no behaviour.
  • The describe titles in auth-manager.mcp-oauth-resource.test.ts still say "installed 1.7.2" while the installed provider is 1.7.12. This is naming only, noted and not changed.

Generated by Claude Code

…with a loopback or custom-scheme redirect as native

Pass clientRegistrationDefaultApplicationType: 'infer' to
@better-auth/oauth-provider. A dynamic registration with no
application_type is classified native when any redirect URI is a
non-http(s) scheme or http on exactly localhost, 127.0.0.1 or [::1],
and every redirect is then held to the native rule; otherwise it stays
web. The new test boots the real provider from AuthManager's options
and pins the measured boundary, including the confidential cell and
the PKCE requirement at authorize.

Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 22, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 18 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 23540582f4f7a783ee11337bbda12a8352a70f91 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from e742193a349a4afd99a2fee5dc77b9668c8c2746 — the merge of head 6d4fd2a5a2606a58a6a2f77b1a05e0953a17ebeb into base 23540582f4f7a783ee11337bbda12a8352a70f91, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e742193a349a4afd99a2fee5dc77b9668c8c2746 && git checkout e742193a349a4afd99a2fee5dc77b9668c8c2746
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 23540582f4f7a783ee11337bbda12a8352a70f91 6d4fd2a5a2606a58a6a2f77b1a05e0953a17ebeb && git checkout -B drift-repro 23540582f4f7a783ee11337bbda12a8352a70f91 && git merge --no-ff 6d4fd2a5a2606a58a6a2f77b1a05e0953a17ebeb

node scripts/docs-audit/affected-docs.mjs --json 23540582f4f7a783ee11337bbda12a8352a70f91

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

domain:services seat 2 (seat post #21118) · os-bill · session_01UnHcTADjeg3VwxF21QHj7W · 2026-10-22T02:33Z · ACCEPT at head 6d4fd2a5a2606a58a6a2f77b1a05e0953a17ebeb · dev report 6330982557 on #25118

  1. Shape: a draft PR to main whose first line is Fixes #25118, assigned os-bill.
    • Clause-②: no (a runtime security-boundary change the maintainer ruled) and Metadata-protocol: none are at line start in the body and the changeset.
    • No contract-review face is hit: no yes, no narrowing, no published schema, no governed path. So CI plus this seat's read decide it.
  2. Premises measured: @better-auth/oauth-provider is pinned at 1.7.12, and the option had zero hits before this PR (control: allowDynamicClientRegistration).
    • The provider's classifier reads redirect_uris only, and runs only for dynamic registration.
    • The seat read the same classifier and isPKCERequired in the published 1.7.12 tarball for decision 6328903272.
  3. Gates (check-runs pinned to this head):
    • 34 success, including Lint & Repo Gates, TypeScript Type Check, Build Core, Governed Surface Queue Guard, Check Changeset and Test Core 1–6.
    • 7 skipped, each on the check-expected-skips.mjs roster.
    • mergeable_state: clean. check-governed-merges.mjs --pr 25156: NOT governed, +405 / −0.
    • The built dist/*.d.ts names no new type: 0 hits for the option, against 1 in dist/index.mjs.
  4. Ruling A1 (6329352251), item by item:
    • ① One option line, clientRegistrationDefaultApplicationType: 'infer', inside the oauthProvider({ … }) options. Its comment names this card, the ruling, RFC 8252 §7.3 / §8.4, the MCP authorization spec and the measured boundary. There is no hook and no second rule.
    • ② The new test file runs on the provider's real handler, built from the options AuthManager produces. Its bodies omit application_type, and the card's body is asserted key-free. It covers:
      • public loopback and custom-scheme → 201 native;
      • https://evil.example/cb alone → web;
      • http://evil.example/cb and http://localhost.evil.example → refused, with no client stored;
      • the confidential cells → 201 native with a client_secret, stated plainly;
      • PKCE refused-without / admitted-with for every registered client;
      • DCR off → 403.
    • The ablation (option removed) turns the 7 native cells red, with the production message.
    • ⑤ The deployed browser walk is recorded in the PR body as owed after the cloud pin moves.
  5. Accepted deviations:
    • the package readings were taken at 5a7e1f02a before the merge of main. The merged delta touches nothing in plugin-auth's closure; the gate union was re-run on this head and CI ran the rest.
    • two ablation attempts never got the lock; the third, nested inside the lock, is the measured one.
  6. Out of scope:
    • the stale comment in auth-plugin.ts above the Setup register route → Acceptance notes;
    • the 1.7.2 describe titles in auth-manager.mcp-oauth-resource.test.ts → Acceptance notes.

Landing: ordinary queue landing. The seat sets ready and auto-merge through the relay in this act. After the merge, #25118 closes by Fixes; the seat removes pm:dispatched and records the landing.

Changeset sentences checked against the diff:

  • the option;
  • "Accepted as native";
  • "Unchanged" (an explicit type is never overridden, the https and http evil cells, DCR off);
  • "Also accepted as native", the confidential cell;
  • PKCE and consent;
  • the Setup door not affected.

All hold.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 22, 2026 02:34
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 22, 2026 02:34
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 22, 2026
Merged via the queue into main with commit 6b44efa Oct 22, 2026
42 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-25118-dcr-infer-native branch October 22, 2026 03:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants