Skip to content

Add RFC 9728 protected resource metadata and WWW-Authenticate on 401 - #3

Merged
christophdb merged 2 commits into
mainfrom
feat/oauth-protected-resource-metadata
Sep 1, 2026
Merged

christophdb merged 2 commits into
mainfrom
feat/oauth-protected-resource-metadata

Conversation

@christophdb

Copy link
Copy Markdown
Member

Why

A user reported on 2026-09-01 that Grok's custom MCP connector cannot connect to mcp.seatable.com: no OAuth window opens at all. Their probes, all reproduced against stage before this change:

Probe 1.6.0
GET /health 200, 1.6.0
POST /mcp without Authorization 401, no WWW-Authenticate
GET /.well-known/oauth-authorization-server 200
GET /.well-known/oauth-protected-resource 404
GET /.well-known/oauth-protected-resource/mcp 404

MCP authorization revision 2025-06-18 requires a resource server to publish RFC 9728 protected resource metadata and to point at it from every 401. We implemented the 2025-03-26 requirements in dff36ac — RFC 8414, PKCE, RFC 7591 — and never revisited the spec after it moved. The v1.6.0 hardening was scoped to an external report's three findings, so it was a remediation rather than a conformance audit.

It stayed invisible because Claude and ChatGPT also probe the legacy /.well-known/oauth-authorization-server path. A client that follows the current spec strictly has nothing to go on and fails before any OAuth flow begins.

Not a vulnerability

Worth stating explicitly, since RFC 9728 is usually discussed in a security context. The property it protects — audience binding — is already enforced here, and more strictly: access tokens are AES-256-GCM sealed envelopes keyed from SEATABLE_TOKEN_SECRET, so a token we issued cannot be opened by anyone else and a foreign token cannot be opened by us. Issuer and resource are the same origin, the same process. Nothing becomes exploitable through the absence of this metadata.

What does change is the future: the moment issuer and resource are no longer the same origin — the planned move to a real authorization endpoint in SeaTable core — that binding stops being emergent and the missing declaration starts to matter. This lays the groundwork.

What changed

src/auth/oauthProvider.tshandleProtectedResourceMetadata(), plus resourceMetadataUrl() and challenge().

authorization_servers and the issuer in the RFC 8414 document both derive from resolveBaseUrl(). That is deliberate and is the main risk this PR carries: once WWW-Authenticate exists, conformant clients follow it instead of the legacy path, so a disagreement between the two documents would break the clients that work today. Sharing one source makes divergence structurally impossible rather than merely tested.

src/http/httpServer.ts — both well-known locations routed (clients differ in which they probe; the reporter probed both), and a single unauthorized() helper that all four 401 sites now go through. Previously each site built its own writeHead — that is how a fifth one would silently ship without the header.

Per RFC 6750 §3.1: no error code when no credential was presented, error="invalid_token" when one was presented and rejected.

Managed mode only. Selfhosted has no OAuth provider, and advertising metadata there would point clients at /authorize and /token that do not exist.

Tests

tests/oauthProtectedResource.spec.ts — 13 tests, written first and red before the fix. Two were green from the start on purpose: they guard that selfhosted keeps answering 404.

Full suite: 313 passed (28 files). Lint and typecheck clean.

Verified on stage

Deployed as testing-1.6.1 on mcp.st-stage.de.

Protocol level — every probe from the report now inverts:

/health                                      -> 1.6.1
/.well-known/oauth-protected-resource        -> 200
/.well-known/oauth-protected-resource/mcp    -> 200
POST /mcp (no auth)   -> 401 + Bearer resource_metadata="..."
POST /mcp (bad token) -> 401 + Bearer error="invalid_token", resource_metadata="..."

AS metadata issuer       : https://mcp.st-stage.de
PR authorization_servers : https://mcp.st-stage.de     agree

Client level, in this order:

  1. Regression — a Claude Code connection established against 1.6.0 before the deploy kept working afterwards. list_tables succeeded, no re-authorization. This is the test that matters for existing Claude and ChatGPT users.
  2. Discovery — connector removed and re-added, full fresh flow through the new path, successful.

Deliberately not included

  • grok.com in DEFAULT_TRUSTED_REDIRECT_HOSTS. A policy decision about which external agents skip the acknowledgement step, not a compliance fix. Grok will work either way; it will see the acknowledgement page once.
  • RFC 8707 resource indicators. Only start to matter once issuer and resource are no longer the same origin. Today they would add nothing the sealing does not already provide.

Known gap in the verification

Claude Code authorizes over a loopback callback, and oauthProvider.ts:196 lets loopback skip the acknowledgement page. The path Grok will actually take was therefore not exercised on stage. It can only be confirmed by the reporter against production.

christophdb and others added 2 commits September 1, 2026 16:07
MCP authorization revision 2025-06-18 requires a resource server to publish
protected resource metadata and to point at it from every 401. We implemented
the 2025-03-26 requirements (RFC 8414, PKCE, RFC 7591) in dff36ac and never
revisited the spec afterwards. It stayed invisible because Claude and ChatGPT
also probe the legacy /.well-known/oauth-authorization-server path; a client
that follows the current spec strictly cannot discover the authorization
server at all and fails before any OAuth window opens.

- Serve the RFC 9728 document at /.well-known/oauth-protected-resource and at
  the resource-path form .../oauth-protected-resource/mcp. Clients differ in
  which they probe, so both answer.
- Send WWW-Authenticate from all four 401 sites via a single unauthorized()
  helper, so a fifth site cannot silently omit it.
- Follow RFC 6750 section 3.1: no error code when no credential was presented,
  error="invalid_token" when one was presented and rejected.

authorization_servers and the issuer in the RFC 8414 document both derive from
resolveBaseUrl(). That is deliberate: once WWW-Authenticate exists, conformant
clients follow it INSTEAD of the legacy path, so a disagreement between the two
documents would break the clients that work today.

Managed mode only. Selfhosted has no OAuth provider, and advertising metadata
there would point clients at /authorize and /token that do not exist.

Not included: grok.com in DEFAULT_TRUSTED_REDIRECT_HOSTS (a policy decision,
not a compliance fix) and RFC 8707 resource indicators (they only start to
matter once issuer and resource are no longer the same origin).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WUg1nG8m7NYmqdToqHsHvK
/health reports pkg.version (src/config/env.ts:10), so a distinct version is
how a stage deploy is confirmed to be the new build rather than the 1.6.0
currently running in production.
@christophdb
christophdb merged commit 558dea0 into main Sep 1, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant