Skip to content

client_credentials sends the credential to an unverified issuer unless issuer= is passed (4.1.2) #3850

Open

Description

@LHMisme420

On fastmcp 4.1.2, ClientCredentialsOAuthProvider without issuer= still posts to the token endpoint named in the protected resource's own metadata, so a server can point it at any authorization server it likes. Passing issuer= rejects it before the token call. 4.1.1 has no issuer argument at all, so the check can't be exercised there. The deprecation warning says the argument will be required in 3.0; the concern is the window before that, where the default is still to follow the server.

Activity

  1. added
    enhancementRequest for a new feature that's not currently supported
    on Nov 12, 2026
  2. Shanmukh-l33t commented on Nov 12, 2026

    @Shanmukh-l33t

    Root cause (current main, 77001c9)

    Without issuer=, ClientCredentialsOAuthProvider posts the client credential to a token endpoint that is never verified to belong to any authorization server:

    1. _require_metadata_for_configured_issuer (src/mcp/client/auth/extensions/client_credentials.py:49) returns immediately when issuer is None, so nothing guards the exchange.
    2. _get_token_endpoint (src/mcp/client/auth/oauth2.py:445-451) falls back to the 2025-03-26 default urljoin(<resource origin>, "/token") when discovery found no authorization server metadata — and _exchange_token_client_credentials then posts client_id/client_secret (Basic header or body) there. A resource whose protected resource metadata names an authorization server that serves no metadata receives the credential at its own /token, with no issuer verified anywhere.
    3. When metadata was found, its issuer is only checked at discovery time (validate_metadata_issuer, src/mcp/client/auth/oauth2.py:716); the exchange itself would build a request from whatever metadata is in the context.

    (1)+(2) is the reported hole, and it matches the report exactly: passing issuer= makes _require_metadata_for_configured_issuer stop the flow before the token call. (The class lives in this repo, src/mcp/client/auth/extensions/client_credentials.py; fastmcp surfaces it.)

    Proposed fix (branch below, no PR — I know unassigned PRs get auto-closed)

    Narrowest change that closes the unverified-issuer window without changing documented behavior: a client-credentials token request is now only built from authorization server metadata for a verified issuer — the configured issuer= when there is one, else the authorization server root the client resolved (the PRM-advertised server, or the resource origin on the legacy path), i.e. the same root discovery already requires the document's issuer to name (RFC 8414 §3.3 / SEP-2468, #3398). Anything else stops with OAuthFlowError before the credential is attached, and the refused metadata and tokens are dropped so the next request rediscovers instead of refreshing against what was refused.

    What this deliberately does not change: a resource can still steer an issuer=-less client to an authorization server of its choosing as long as that server serves self-consistent metadata — the documented deprecation-window default, pinned by test_without_issuer_the_exchange_follows_whichever_server_was_discovered. Closing that window means making issuer= required now instead of in 3.0, i.e. flipping the documented default early — a breaking change, so that call is yours. If you want it early, it is essentially making _checked_issuer raise instead of warn and updating the tests pinned to the follow-the-server default.

    Tests (regression-first, tests/client/auth/extensions/test_client_credentials.py; 2 tests × {client-secret, private-key-jwt})

    • test_without_issuer_the_exchange_refuses_when_no_metadata_was_discovered — full auth flow: 401 → PRM advertises https://elsewhere.example.com → every AS well-known 404s. Before: the secret provider builds POST https://api.example.com/token carrying the secret; after: OAuthFlowError before any request is built.
    • test_without_issuer_the_exchange_refuses_metadata_for_an_unresolved_issuer — exchange-level guard: metadata whose issuer names an issuer other than the resolved root. Before: the request is built (and the jwt assertion is minted for that issuer); after: refused, metadata + tokens dropped.

    RED on unmodified main — 4 failed (2 tests × 2 providers):

    • ..._refuses_when_no_metadata_was_discovered[secret] — Failed: DID NOT RAISE OAuthFlowError: the token request carrying the credential was built for the resource origin's /token
    • ..._refuses_metadata_for_an_unresolved_issuer[secret] — Failed: DID NOT RAISE OAuthFlowError: request built for the unverified issuer's token endpoint
    • ..._refuses_metadata_for_an_unresolved_issuer[jwt] — Failed: DID NOT RAISE OAuthFlowError: assertion minted with the unverified issuer as audience
    • ..._refuses_when_no_metadata_was_discovered[jwt] — AssertionError: raised Missing OAuth metadata for private_key_jwt flow instead of the precise refusal

    GREEN with the fix: tests/client/auth/extensions/test_client_credentials.py — 33 passed (29 pre-existing + 4 new). Targeted neighbors (tests/client tests/shared tests/docs_src) — 2753 passed, 1 xfailed. Full suite (coverage run -m pytest, serial — the -n 2 run loses xdist worker coverage data in this sandbox) — 6236 passed, 1 failed, 8 skipped, 1 xfailed; the one failure is tests/server/mcpserver/resources/test_file_resources.py::test_permission_error, which fails identically on pristine main in this sandbox (it runs as root, so chmod 0o000 doesn't block the read). coverage report — exit 0, TOTAL 100.00% (54333 statements / 0 missed, 4258 branches / 0 partial; the repo gate is branch coverage fail_under = 100). strict-no-cover — clean. Lint: ruff check + ruff format --check clean on both changed files.

    Compare: main...Shanmukh-l33t:python-sdk:fix/3850-verify-issuer-before-token-request

    This fix and its tests were written with AI assistance (Hermes Agent).

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

    enhancementRequest for a new feature that's not currently supported

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions