Skip to content

Silent OAuth refresh drops the resource_metadata URL learned from WWW-Authenticate, re-discovers the wrong AS, and wipes valid credentials #462

Open

Description

@nannan00

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-Authenticate headers when present

and servers only need to implement one of the two mechanisms:

MCP servers MUST implement one of the following discovery mechanisms:

  1. WWW-Authenticate Header: Include the resource metadata URL in the WWW-Authenticate HTTP header …
  2. 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():

const serverInfo = await discoverOAuthServerInfo(definition.command.url, { fetchFn: boundedRefreshFetch });

const serverInfo = await discoverOAuthServerInfo(definition.command.url, { fetchFn: boundedRefreshFetch });
// ❌ no resourceMetadataUrl → default well-known probing

For 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

  1. 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/mcp returns 401 with:
      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_metadata URL 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/mcp and /.well-known/oauth-protected-resource both return 404 (spec-compliant: the WWW-Authenticate header is the advertised mechanism).
  2. Configure:
    {
      "mcpServers": {
        "my-server": {
          "url": "https://mcp.example.com/v2/server/mcp"
        }
      }
    }
  3. mcporter auth my-server → PRM discovered from the header → OAuth succeeds ✅
  4. 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)

Activity

  1. added
    clawsweeper:intake:bugPreliminary submission classification; full source review pending.
    P2Normal priority bug or improvement with limited blast radius.
    clawsweeper:fix-shape-clearClawSweeper found a clear likely implementation shape for this issue.
    clawsweeper:queueable-fixClawSweeper marked this issue as an existing queue_fix_pr work candidate.
    clawsweeper:source-reproClawSweeper found a high-confidence source-level issue reproduction.
    impact:auth-providerThis issue is about auth, provider routing, model choice, or SecretRef resolution.
    impact:data-lossThis issue is about lost, corrupted, or silently dropped user/session/config data.
    issue-rating: 🦞 diamond lobsterVery strong issue quality with high-confidence source-level or clear reproduction.
    no-staleExempts this issue from stale automation.
    and removed
    clawsweeper:intake:bugPreliminary submission classification; full source review pending.
    on Oct 23, 2026
  2. clawsweeper commented on Oct 23, 2026

    @clawsweeper
    Contributor

    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: canonical
    Canonical: #462
    Summary: The explicitly linked open PR addresses the saved-discovery omission described by this issue.

    Members:

    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:

    • Current main retains the omission: GitHub main and the checkout both resolve to this revision; refresh passes only the server URL and bounded fetch to discovery, without reading persisted discovery state. (src/oauth-token-refresh.ts:360, 2874448832eb)
    • Wrong discovery can delete valid credentials: An issuer mismatch clears both tokens and client registration before throwing; this explains the reported credential loss without assuming a live reproduction. (src/oauth-token-refresh.ts:231, 2874448832eb)
    • Saved discovery exists outside proactive refresh: The provider exposes persisted discovery state, and existing tests round-trip its resourceMetadataUrl through directory and vault stores; provider token reads perform proactive refresh before returning credentials to the SDK. (src/oauth.ts:391, 2874448832eb)
    • Imported SDK contract confirms the failure path: The target directly imports discoverOAuthServerInfo from its pinned client 2.3.1 dependency. At the verified v2.3.1 commit, this helper accepts resourceMetadataUrl and can fall back to the MCP origin after unsuccessful protected-resource discovery; authInternal separately restores the cached URL. The target's local client patch does not modify this helper. (packages/client/src/client/auth.ts:2019, fcef852f6cda)
    • Open fixing PR requires keeping the issue open: fix(oauth): reuse protected resource metadata on refresh #463 explicitly closes this issue and remains unmerged. Its current production diff restores the saved URL under the refresh lock; its updated body reports shipped-CLI success and issuer-substitution rejection. Those execution results were not independently run during this review. (src/oauth-token-refresh.ts:364, ca83da5eb96f)
    • History and policy checked: Read the complete root AGENTS.md and VISION.md. Feature history follows the earlier oauth-persistence.ts implementation, SDK migration, issuer binding, and cross-process serialization. REST commit patches confirmed relevant contributions after local blame and some historical reads failed on unavailable promisor objects; no introduction claim relies on those failures. (AGENTS.md:1, 2874448832eb)
    How this review workflow works
    • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
    • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
    • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
    • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
    • Maintainers can also comment @clawsweeper review to request a fresh review only.
    • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge; those execution lanes are retired.
    • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.

    Reviewed October 24, 2026, 7:41 PM ET / 23:41 UTC.

  3. added
    clawsweeper:intake:bugPreliminary submission classification; full source review pending.
    and removed
    clawsweeper:intake:bugPreliminary submission classification; full source review pending.
    clawsweeper:queueable-fixClawSweeper marked this issue as an existing queue_fix_pr work candidate.
    on Oct 23, 2026
  4. 24 remaining items

  5. added
    clawsweeper:intake:bugPreliminary submission classification; full source review pending.
    and removed
    clawsweeper:intake:bugPreliminary submission classification; full source review pending.
    on Oct 24, 2026
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

    P2Normal priority bug or improvement with limited blast radius.clawsweeper:linked-pr-openClawSweeper found an open linked pull request for this issue.clawsweeper:no-new-fix-prClawSweeper does not recommend queueing a new automated fix PR for this issue.clawsweeper:source-reproClawSweeper found a high-confidence source-level issue reproduction.impact:auth-providerThis issue is about auth, provider routing, model choice, or SecretRef resolution.impact:data-lossThis issue is about lost, corrupted, or silently dropped user/session/config data.issue-rating: 🦞 diamond lobsterVery strong issue quality with high-confidence source-level or clear reproduction.

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions