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
-
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"
-
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
-
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
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-authconfigures@better-auth/oauth-providerwithvalidAudiences: [authIssuer, mcpResourceUrl], but oauth-provider 1.7.2 (the version plugin-auth declares via^1.7.2) no longer has that option — the stringvalidAudiencesdoes not occur anywhere in its dist. In 1.7.2 an RFC 8707resourceis resolved from theoauthResourcetable (sys_oauth_resource), andenforcePerClientResourcesdefaults totrue, so each OAuth client must also be linked inoauthClientResource. Neither row is ever written, so any client that sendsresource=(Claude Code does) is refused at/oauth2/authorize.This is independent of http/https and of the desktop-app
must be httpsrefusal — it is the next wall behind it.Environment
789a7324(main),@objectstack/*17.3.0,better-auth1.7.2,@better-auth/oauth-provider1.7.2 (hoisted; plugin-auth declares^1.7.2for both)objectstack dev -p 4001withOS_AUTH_URL=https://localhost:4443, behind a local TLS reverse proxy (self-signed CA trusted in the system keychain){type: http, url: https://localhost:4443/api/v1/mcp}, no headersReproduction
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 allhttps://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"In Claude Code:
/mcp→ Connect. Dynamic client registration succeeds (a newsys_oauth_applicationrowClaude 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:Database after the attempt:
sys_oauth_resource0 rows,sys_oauth_client_resource0 rows,sys_oauth_application6 rows (one per Connect attempt over three days — a fresh client each time),sys_oauth_consent/sys_oauth_access_token/sys_oauth_refresh_tokenall 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". Noresources,clientRegistrationDefaultResources,enforcePerClientResourcesorresourcePrivilegesare passed.@better-auth/oauth-provider1.7.2, token/authorize path: for every requested resourcegetResource(ctx, opts, identifier)reads theoauthResourcemodel; a miss throwsinvalid_target … is not configured. ThenresolveEnforcePerClientResources(opts)→{ value: true, source: "default" }andassertClientLinkedToResourcesrequires anoauthClientResourcerow 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:getMcpResourceUrl()) as anoauthResourcerow at boot (or via the plugin'sresourcesoption, whichever 1.7.2 honours), andclientRegistrationDefaultResources: [mcpResourceUrl]so DCR-registered clients are linked automatically, or setenforcePerClientResources: falsefor the MCP resource — a client that registers anonymously one second before the login cannot be linked by an admin in between.Side observations
http://localhost:4001/api/v1/mcpand aclaude mcp add … http://localhost:4001/…line even whenOS_AUTH_URL=https://localhost:4443is set and the➜ MCP:line above it correctly says https. The hint is built from the listen port, not the canonical origin.AuthManagerstill carries two more independentbasePathnormalisers —getAuthIssuer()andgetMcpResourceUrl()— and one of them builds a malformed URL #16399 (URL normalisation ingetAuthIssuer/getMcpResourceUrl). The values here were well-formed; the problem is that they are handed to an option the provider no longer reads.run_actionon a screen flow dead-ends: the screen pauses even when every input is bound, andlist_actionsnever surfaces flow input variables #15705 / MCPrun_actionon a flow action answersok: trueand starts the run on a row the caller cannot read (or that does not exist) — the flow door still turns a denied load into an implicit grant #16370 (MCPrun_actionon screen flows).