Skip to content

MCP OAuth cannot complete on 17.3.0: plugin-auth passes validAudiences, which @better-auth/oauth-provider 1.7.2 no longer reads — every resource= request fails with invalid_target … is not configured #16530

Description

@yinlianghui

Summary

MCP OAuth cannot complete on 17.3.0: the authorization server rejects every MCP client with invalid_target: requested resource <mcp url> is not configured. @objectstack/plugin-auth configures @better-auth/oauth-provider with validAudiences: [authIssuer, mcpResourceUrl], but oauth-provider 1.7.2 (the version plugin-auth declares via ^1.7.2) no longer has that option — the string validAudiences does not occur anywhere in its dist. In 1.7.2 an RFC 8707 resource is resolved from the oauthResource table (sys_oauth_resource), and enforcePerClientResources defaults to true, so each OAuth client must also be linked in oauthClientResource. Neither row is ever written, so any client that sends resource= (Claude Code does) is refused at /oauth2/authorize.

This is independent of http/https and of the desktop-app must be https refusal — it is the next wall behind it.

Environment

  • hotcrm 789a7324 (main), @objectstack/* 17.3.0, better-auth 1.7.2, @better-auth/oauth-provider 1.7.2 (hoisted; plugin-auth declares ^1.7.2 for both)
  • dev server objectstack dev -p 4001 with OS_AUTH_URL=https://localhost:4443, behind a local TLS reverse proxy (self-signed CA trusted in the system keychain)
  • Client: Claude Code desktop (Claude for Mac 1.46388.x, Claude Code 2.1.260), MCP entry {type: http, url: https://localhost:4443/api/v1/mcp}, no headers

Reproduction

  1. Discovery is correct after OS_AUTH_URL:

    • /.well-known/oauth-protected-resource → {"resource":"https://localhost:4443/api/v1/mcp","authorization_servers":["https://localhost:4443/api/v1/auth"], …}
    • /.well-known/oauth-authorization-server → issuer / authorization / token / registration endpoints all https://localhost:4443/api/v1/auth/…
    • GET /api/v1/mcp → 401, www-authenticate: Bearer realm="ObjectStack MCP", resource_metadata="https://localhost:4443/.well-known/oauth-protected-resource"
  2. In Claude Code: /mcp → Connect. Dynamic client registration succeeds (a new sys_oauth_application row Claude Code (hotcrm) with scopes ["openid","profile","email","offline_access","data:read","data:write","actions:execute"]), the system browser opens, and the flow ends on the error page:

    Authentication failed — Close this tab and try again from Claude Code.
    invalid_target: requested resource https://localhost:4443/api/v1/mcp is not configured

  3. Database after the attempt: sys_oauth_resource 0 rows, sys_oauth_client_resource 0 rows, sys_oauth_application 6 rows (one per Connect attempt over three days — a fresh client each time), sys_oauth_consent / sys_oauth_access_token / sys_oauth_refresh_token all 0. No token has ever been minted.

Where it happens

  • @objectstack/plugin-auth (dist, oauthProvider({...}) options): validAudiences: [this.getAuthIssuer(), this.getMcpResourceUrl()] with the comment "the AS only mints audiences it knows". No resources, clientRegistrationDefaultResources, enforcePerClientResources or resourcePrivileges are passed.
  • @better-auth/oauth-provider 1.7.2, token/authorize path: for every requested resource getResource(ctx, opts, identifier) reads the oauthResource model; a miss throws invalid_target … is not configured. Then resolveEnforcePerClientResources(opts) → { value: true, source: "default" } and assertClientLinkedToResources requires an oauthClientResource row per client.
  • grep -c validAudiences node_modules/@better-auth/oauth-provider/dist/*.mjs → 0 in every file.

Expected

An MCP client following the advertised discovery documents (RFC 9728 → RFC 8414 → DCR → PKCE authorize with resource=<mcp url>) should reach the consent screen and receive a token audienced to the MCP resource. Concretely, plugin-auth should:

  • register the MCP resource (getMcpResourceUrl()) as an oauthResource row at boot (or via the plugin's resources option, whichever 1.7.2 honours), and
  • either set clientRegistrationDefaultResources: [mcpResourceUrl] so DCR-registered clients are linked automatically, or set enforcePerClientResources: false for the MCP resource — a client that registers anonymously one second before the login cannot be linked by an admin in between.

Side observations

Activity

  1. added theissue type on Sep 8, 2026
  2. os-zhuang commented on Sep 8, 2026

    @os-zhuang
    Contributor

    分诊:domain:services / bug / p1 / pm:queue / type Bug

    车道:修复面是 packages/plugins/plugin-auth/src/auth-manager.ts:3411 的 oauthProvider({...}) 调用 —— 车道表 domain:services 行(plugin-auth)。⛔ 不是 domain:cli:卡面顺带提的"CLI 启动横幅印 http" 是另一个面(见下"拆出")。

    准入 (a) 可复现缺陷,卡面把三段证据都钉住了:discovery 文档正确 → DCR 成功(sys_oauth_application 6 行)→ 授权端拒绝,且 sys_oauth_resource / sys_oauth_client_resource 0 行、sys_oauth_access_token 0 行。有"该有行而没有行"的正读数,不是靠"没看见 token"推断。

    我在 origin/main(5e53d73d)上复核到的,以及卡面没写的一条

    git grep -n validAudiences origin/main -- packages/plugins/plugin-auth/src/
      auth-manager.ts:3445                 validAudiences: [this.getAuthIssuer(), this.getMcpResourceUrl()],
      auth-manager.mcp-oauth.test.ts:301   expect(opts.validAudiences).toContain('…/api/v1/mcp');
      auth-manager.mcp-oauth.test.ts:302   expect(opts.validAudiences).toContain('…/api/v1/auth');
    git grep -n 'enforcePerClientResources|clientRegistrationDefaultResources|resourcePrivileges' \
              origin/main -- packages/plugins/plugin-auth/src/   -> 0(一个都没传)
    package.json: "@better-auth/oauth-provider": "1.7.2"(钉死版本,不是浮动范围)
    

    卡面没写、但解释了"为什么这个洞能活下来"的是那两行测试。 auth-manager.mcp-oauth.test.ts 把 oauthProvider mock 掉,取 mock.calls.at(-1)[0],然后断言我们传进去的那个 options 对象里有 validAudiences。它从头到尾没有让 provider 消费过这个选项 —— 所以无论 1.7.2 读不读它,这条断言都绿。一条不可能失败的断言,和一条通过了的断言,是分不出来的;这里正是那种情况。更糟的是它上面那行注释写着「the MCP resource must be a valid audience or token minting fails」,把 1.7.2 上已经不成立的机制当作理由固化了下来。

    ⚠️ 我没能核的一轴:node_modules/@better-auth/oauth-provider/dist/ 在本环境未安装,所以「1.7.2 的 dist 里 validAudiences 出现 0 次」这条我没有独立验证,它仍只有卡面作者的一次读数。承接的人请把它当作待复现的第一步,⛔ 不要因为我复核过 in-repo 的部分就顺带认为 vendor 那轴也核过了。

    p1 的理由

    MCP OAuth 在 17.3.0 上完全无法完成:三天六次尝试,一个 token 都没有铸出。且没有应用侧规避 —— 卡面自己指出关键点:匿名 DCR 注册的客户端在注册与登录之间只隔一秒,管理员无法在这中间去 oauthClientResource 里补一行链接。不是"慢"或"难用",是这条路不通,而且不通的是对外宣传的 discovery 契约(RFC 9728 → 8414 → DCR → PKCE)所承诺的终点。

    验收口径(承接 PR 请照抄进 ## 验收备注)

    1. 先修测试的可失败性,再修代码。 auth-manager.mcp-oauth.test.ts 里那条断言必须改成能被"provider 不消费该选项"这一事实推翻的形状 —— 例如断言真实 provider 在 resource=<mcp url> 的授权请求下不再返回 invalid_target,而不是断言我们传了什么。⛔ 只要断言的对象还是「我们传进去的 options」,这次修完下次换版本还会静默复发。
    2. 端到端一次:discovery → DCR → authorize?resource=<mcp url> → 同意页 → token,且铸出的 token 的 audience 指向 MCP resource。贴出 sys_oauth_resource / sys_oauth_client_resource / sys_oauth_access_token 三张表修复前后的行数。
    3. 两条候选路线(注册 oauthResource 行 + clientRegistrationDefaultResources,或对 MCP resource 关掉 enforcePerClientResources)任选其一即可,但 PR 必须写明为什么选它 —— 后者放宽的是"哪个客户端能拿这个 audience",是安全面,需要一句明确的取舍陈述。
    4. 顺带清掉 validAudiences 这个死选项,⛔ 不要留着"传了但没人读"的字段:它是这次误判的来源。

    拆出(不在本卡)

    卡面「Side observations」第一条 —— CLI 启动横幅从监听端口而非 canonical origin 拼 http://localhost:4001/api/v1/mcp,在 OS_AUTH_URL=https://… 下印错 —— 是 packages/cli 的独立面,与本卡的 provider 选项无关,也不该让这张 p1 去承接一次 CLI 包的验证。已单独归档,⛔ 不要顺手改进本 PR。

    本席权限声明:分诊席只分类/定级/定车道。⛔ 不认领、⛔ 不派发、⛔ 不写代码、⛔ 不合并、⛔ 不裁决决策箱卡。此卡不入决策箱:方向由 1.7.2 的 API 决定,不迁移存量数据形状,也不删除已发布能力;第 3 条的两条路线是承接者带取舍陈述的工程选择,不是需要维护者裁的岔路。


    Generated by Claude Code

  3. added
    priority:p1High: required for production / M2
    and removed on Sep 8, 2026
  4. os-zhuang commented on Sep 8, 2026

    @os-zhuang
    Contributor

    更正:优先级标签打错了词汇表(p1 → priority:p1),已修

    上一条分诊我给这张卡打的是裸标签 p1。仓库里确实存在这个标签,但取卡序读的不是它:SKILL.md:461 的全序是 priority:p0 > pm:blocking > target: 板上项 > p1 > p2 > p3 > 无级,同级先 Bug 再卡龄,而级别来自 priority:p* 一族(SKILL.md:117 即以 priority:p0 立表)。

    所以打成裸 p1 的后果不是"标签不好看",是这张卡在队列里排在"无级"档,也就是最后。 一张"MCP OAuth 在 17.3.0 上完全无法完成、三天没铸出过一个 token"的卡,被我标成了取卡序里的最低档。定级是对的,承载定级的字段是错的 —— 而错误的方向恰好是让它看起来已经定过级,所以不会有人来问。

    已改为 priority:p1,其余标签不变。

    波及面我量了,不是估的:按裸标签 p1 / p2 / p3 分别列开放卡,总数 1 / 2 / 2,全部是我这一批刚写的五张(#16530、#16535、#16534、#16533、#16734),五张已全部改正。本次会话更早的输出用的都是 priority:p*,没有被波及 —— 这个错是这一批里新起的,不是一直在漏。

    给后来者的一句:仓库里同时存在两套优先级标签,p2 和 priority:p2 都能打上去、都不会报错。⛔ 不要凭"标上去了"判断标对了 —— 打完之后按 priority:p* 回查一次,查不到就是没进队列。


    Generated by Claude Code

  5. yinlianghui commented on Sep 8, 2026

    @yinlianghui
    CollaboratorAuthor

    Environment note, for whoever picks this up. Everything in this card was observed through Claude Code inside the Claude desktop app (Claude for Mac 1.46388.x, bundled Claude Code 2.1.260) against a local hotcrm dev server. Because the desktop client refuses to run OAuth against a plain-http:// server, the local setup used a self-signed CA + a small reverse proxy on https://localhost:4443 with OS_AUTH_URL=https://localhost:4443 so the discovery documents advertise https.

    That client and that proxy are only how the path was reached. The defect itself is server-side and client-independent: any OAuth-capable MCP client (Claude Code CLI, Cursor, VS Code, a claude.ai custom connector) hits the same authorization-server / security-layer behaviour once it gets past TLS. Nothing here depends on the desktop app.

  6. os-zhuang commented on Sep 8, 2026

    @os-zhuang
    Contributor

    Contract-review handoff — PR #16780 @ 5cd3b999a: CHANGES REQUIRED → patch round (director seat, 2026-09-08)

    Review: #16780 (comment) (isolated CONTRACT_REVIEW_TIER seat). Reviewed-by: director seat session_01TezFG8ZMrNH6n5VTNpPpdH (isolated fable subagent). Implemented-by: branch claude/issue-16530-mcp-oauth-resource-registration.

    Owed before re-review: F1 — the PR declares Clause-②: yes (accept set of /oauth2/authorize grows) while grading @objectstack/plugin-auth patch; per the batch #35 WHICH LEVEL ruling a new accepted value takes at least minor — change the level (the LEVEL-axis gate could not see packages/plugins/*, #16713). F2 — add the missing negative control: an unregistered resource= still answers invalid_target against the real provider. F3 is a body-wording note, no code.

    State: carrier removed from card and PR (review concluded); re-hang both with the patched head. Card stays pm:dispatched, assignee unchanged.


    Generated by Claude Code

  7. os-zhuang commented on Sep 8, 2026

    @os-zhuang
    Contributor

    Re-review PASS → landed (director seat, 2026-09-08 14:1xZ)

    • PR: fix(plugin-auth): register the MCP resource so RFC 8707 authorize can succeed #16780 @ 28c7da91e — re-review fix(plugin-auth): register the MCP resource so RFC 8707 authorize can succeed #16780 (comment) (claude-fable-5-1, isolated seat). F1–F4 of the first review all discharged by measurement; route (a) as dispatched: enforcePerClientResources stays at its true default, the one MCP resource is seeded and linked at registration, allowUnauthenticatedClientRegistration unchanged. CI 37 success / 0 failure; non-governed.
    • Handoff by label: needs:contract-review dropped on the PR and on this card; PR undrafted; auto-merge (squash) armed. Card stays pm:dispatched until the merge closes it via Fixes #16530.
    • For the maintainer, record only (reviewer F3 / N1): after this lands every client created through the provider's registration path holds the MCP audience; the per-client check goes on discriminating only for non-DCR clients. That is the designed outcome for public MCP clients and the route the dispatch ordered; route (b) — switching the check off — stays the maintainer floor and was not taken.

    Reviewed-by: director seat (contract review tier claude-fable-5-1)
    Implemented-by: domain:services seat (os-trump, session session_012zTkyNHJ7TkuN2oXtP5x37)


    Generated by Claude Code

  8. github-actions commented on Sep 8, 2026

    @github-actions
    Contributor

    os-closed-card-sweep — machine-findable marker for this generated comment.

    Removed the pm-loop state label(s) this closed card no longer claims: pm:dispatched.

    A state label claims work is in flight. This card is closed on a merged delivery, so the claim
    is stale; every other label is left exactly as it was found. Nothing here is a judgement about
    the card, and no verdict-bearing label is ever touched by this sweep.

    posted by half-state-patrol run 34270387866 · trigger schedule

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions