Silent OAuth refresh drops the resource_metadata URL learned from WWW-Authenticate, re-discovers the wrong AS, and wipes valid credentials #462
Description
What's Wrong?
Initial authorization works for MCP servers that advertise Protected Resource Metadata (PRM) only via the WWW-Authenticate header: the transport extracts resource_metadata from the 401, passes it to the SDK's auth(), and the SDK persists it as discoveryState.resourceMetadataUrl.
But when the access token approaches expiry, mcporter's own non-interactive refresh re-runs discovery from the server URL only, ignoring the persisted discoveryState.resourceMetadataUrl. The SDK then falls back to well-known URI probing, which fails when PRM is served at a non-default location (common behind API gateways and on multi-tenant platforms that only route a path prefix to each backend), and finally treats the MCP server origin as the Authorization Server.
Because the origin differs from the issuer bound to the stored tokens, assertRefreshIssuerBinding concludes the issuer changed and deletes both the tokens and the client registration. Every token expiry turns into a forced re-authorization, and headless/daemon callers simply break.
Per MCP Authorization spec (2025-11-25):
MCP clients MUST support both discovery mechanisms and use the resource metadata URL from the parsed
WWW-Authenticateheaders when present
and servers only need to implement one of the two mechanisms:
MCP servers MUST implement one of the following discovery mechanisms:
- WWW-Authenticate Header: Include the resource metadata URL in the
WWW-AuthenticateHTTP header …- Well-Known URI: Serve metadata at a well-known URI …
The resource_metadata value is an arbitrary URL (RFC 9728 §5.1), so a server that only serves PRM at the header-advertised URL is spec-compliant, and works with mcporter until its first token refresh.
Servers that serve PRM at the default well-known path are unaffected: the SDK only persists resourceMetadataUrl when it came from the WWW-Authenticate header or from cached state, so only servers relying on a header-advertised URL have a stored value to lose.
Root Cause
Initial auth — correct. The transport's 401 handler forwards the header value to the SDK:
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(ctx.response);
await auth(provider, { serverUrl: ctx.serverUrl, resourceMetadataUrl, scope, ... }); // ✅and the SDK stores it in discoveryState via provider.saveDiscoveryState().
Silent refresh — drops it. refreshCachedOAuthTokenUnderLock reads tokens and client info, but never calls persistence.readDiscoveryState():
mcporter/src/oauth-token-refresh.ts
Line 360 in 5f9eee1
| const serverInfo = await discoverOAuthServerInfo(definition.command.url, { fetchFn: boundedRefreshFetch }); |
const serverInfo = await discoverOAuthServerInfo(definition.command.url, { fetchFn: boundedRefreshFetch });
// ❌ no resourceMetadataUrl → default well-known probingFor comparison, the SDK's own authInternal restores it before discovery:
let effectiveResourceMetadataUrl = resourceMetadataUrl;
if (!effectiveResourceMetadataUrl && cachedState?.resourceMetadataUrl) {
effectiveResourceMetadataUrl = new URL(cachedState.resourceMetadataUrl); // ✅
}The SDK path cannot rescue this case: provider.tokens() → refreshedTokens() deliberately refreshes under the cross-process lock before the SDK sees an expired token (and throws OAuthRefreshUnavailableError rather than hand it over), so mcporter's path is the only proactive refresh path.
What Should Happen?
Silent refresh should discover the same Authorization Server as the initial authorization by passing the persisted discoveryState.resourceMetadataUrl to discoverOAuthServerInfo, then redeem the refresh token at https://auth.example.com.
Error Messages/Logs
No production logs attached; the failure is deterministic from source. The trace below is derived from main @ 5f9eee1 using the example topology in "Steps to Reproduce".
Initial auth (mcporter auth my-server):
POST https://mcp.example.com/v2/server/mcp → 401
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/v2/server/mcp/.well-known/oauth-protected-resource",
scope="files:read"
→ GET header URL → PRM: authorization_servers: ["https://auth.example.com"]
→ tokens saved with issuer https://auth.example.com
→ discoveryState.resourceMetadataUrl persisted ✅
~60s before expiry (any tool call):
provider.tokens() → refreshedTokens() → refreshCachedOAuthTokenUnderLock()
→ discoverOAuthServerInfo("https://mcp.example.com/v2/server/mcp") (no resourceMetadataUrl)
GET https://mcp.example.com/.well-known/oauth-protected-resource/v2/server/mcp → 404
(path-insertion per RFC 9728 — gateway only routes /v2/server/* to the backend)
GET https://mcp.example.com/.well-known/oauth-protected-resource → 404
(root fallback — not served either)
→ PRM discovery fails; SDK last resort: AS = https://mcp.example.com/
GET https://mcp.example.com/.well-known/oauth-authorization-server → 404
GET https://mcp.example.com/.well-known/openid-configuration → 404
→ assertRefreshIssuerBinding: expected https://auth.example.com, received https://mcp.example.com/
→ tokens + client registration cleared ❌With MCPORTER_LOG_LEVEL=debug, the code paths above emit:
Failed to refresh cached OAuth token for 'my-server' non-interactively: OAuth issuer changed for 'my-server': expected https://auth.example.com, received https://mcp.example.com/. Discarded cached credentials; run 'mcporter auth my-server' to reauthorize.
If the stored tokens carry no issuer (older credentials), the binding check is skipped and refresh instead POSTs to https://mcp.example.com/token → 404, surfacing as OAuthRefreshUnavailableError.
Steps to Reproduce
- Set up a Streamable HTTP MCP server with RS and AS as separate services, behind a gateway that only routes
/v2/server/*to the RS backend:- RS at
https://mcp.example.com/v2/server/mcpreturns401with:HTTP/1.1 401 Unauthorized WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/v2/server/mcp/.well-known/oauth-protected-resource", scope="files:read"
- PRM at the above
resource_metadataURL returns:{ "resource": "https://mcp.example.com/v2/server/mcp", "authorization_servers": ["https://auth.example.com"] } - AS at
https://auth.example.com(separate service) issues refresh tokens with a short access-token TTL. https://mcp.example.com/.well-known/*is not routed to any backend, so/.well-known/oauth-protected-resource/v2/server/mcpand/.well-known/oauth-protected-resourceboth return 404 (spec-compliant: theWWW-Authenticateheader is the advertised mechanism).
- RS at
- Configure:
{ "mcpServers": { "my-server": { "url": "https://mcp.example.com/v2/server/mcp" } } } mcporter auth my-server→ PRM discovered from the header → OAuth succeeds ✅- Wait until the access token is within 60s of expiry, then run
mcporter list my-server→ refresh re-discovers the wrong AS, credentials are wiped, re-authorization required ❌
Environment
- mcporter: main @ 5f9eee1 (source analysis)
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Activity
clawsweeper commentedon Oct 23, 2026
Codex review: this still needs some work.
Summary
Current main still contains the source-proven refresh defect, and an explicitly linked open PR owns the repair.
Reproducibility: Current source establishes the failure path: refresh omits the persisted metadata URL, SDK discovery can fall back to the resource origin, and issuer mismatch clears credentials; no live reproduction was run.
Root-cause cluster
Relationship:
canonicalCanonical: #462
Summary: The explicitly linked open PR addresses the saved-discovery omission described by this issue.
Members:
fixed_by_candidate: fix(oauth): reuse protected resource metadata on refresh #463 - Its body explicitly closes this issue, and its production diff restores persisted resourceMetadataUrl during refresh.Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything.
Next step
Review the updated header-only refresh and issuer-rejection coverage in #463, and keep this issue open until the fix merges.
Security
None.
Review details
Best possible solution:
Restore the saved metadata URL within the existing refresh transaction while preserving issuer validation, resource checks, and credential-generation safeguards.
Do we have a high-confidence way to reproduce the issue?
Current source establishes the failure path: refresh omits the persisted metadata URL, SDK discovery can fall back to the resource origin, and issuer mismatch clears credentials; no live reproduction was run.
Is this the best way to solve the issue?
Forwarding the existing saved URL through the SDK's supported discovery option is a focused repair; the linked PR also covers legacy issuer binding and divergent saved state, which need review before landing.
AGENTS.md: found and applied where relevant.
Codex review notes: model internal, reasoning medium; reviewed against 2874448832eb.
Label changes
Label changes:
No label changes.
Label justifications:
P2: The defect affects a specific OAuth deployment topology; urgent production incidence is not established.impact:auth-provider: Silent refresh can discover the wrong authorization server and force reauthorization.impact:data-loss: The resulting issuer mismatch deletes persisted tokens and client registration.Evidence reviewed
What I checked:
src/oauth-token-refresh.ts:360, 2874448832eb)src/oauth-token-refresh.ts:231, 2874448832eb)src/oauth.ts:391, 2874448832eb)packages/client/src/client/auth.ts:2019, fcef852f6cda)src/oauth-token-refresh.ts:364, ca83da5eb96f)AGENTS.md:1, 2874448832eb)How this review workflow works
@clawsweeper re-reviewcomments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.@clawsweeper re-reviewor@clawsweeper re-runon an open PR or issue to request a fresh review only.@clawsweeper reviewto request a fresh review only.@clawsweeper explainto ask for more context, or@clawsweeper stopto stop active automation.Reviewed October 24, 2026, 7:41 PM ET / 23:41 UTC.
24 remaining items