You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
client_credentials sends the credential to an unverified issuer unless issuer= is passed (4.1.2) #3850
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.
Without issuer=, ClientCredentialsOAuthProvider posts the client credential to a token endpoint that is never verified to belong to any authorization server:
_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.
_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.
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.
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.
Activity
Shanmukh-l33t commentedon Nov 12, 2026
Root cause (current
main,77001c9)Without
issuer=,ClientCredentialsOAuthProviderposts the client credential to a token endpoint that is never verified to belong to any authorization server:_require_metadata_for_configured_issuer(src/mcp/client/auth/extensions/client_credentials.py:49) returns immediately whenissuer is None, so nothing guards the exchange._get_token_endpoint(src/mcp/client/auth/oauth2.py:445-451) falls back to the 2025-03-26 defaulturljoin(<resource origin>, "/token")when discovery found no authorization server metadata — and_exchange_token_client_credentialsthen postsclient_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.issueris 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_issuerstop 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'sissuerto name (RFC 8414 §3.3 / SEP-2468, #3398). Anything else stops withOAuthFlowErrorbefore 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 bytest_without_issuer_the_exchange_follows_whichever_server_was_discovered. Closing that window means makingissuer=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_issuerraise 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 advertiseshttps://elsewhere.example.com→ every AS well-known 404s. Before: the secret provider buildsPOST https://api.example.com/tokencarrying the secret; after:OAuthFlowErrorbefore any request is built.test_without_issuer_the_exchange_refuses_metadata_for_an_unresolved_issuer— exchange-level guard: metadata whoseissuernames 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: raisedMissing OAuth metadata for private_key_jwt flowinstead of the precise refusalGREEN 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 2run loses xdist worker coverage data in this sandbox) — 6236 passed, 1 failed, 8 skipped, 1 xfailed; the one failure istests/server/mcpserver/resources/test_file_resources.py::test_permission_error, which fails identically on pristinemainin this sandbox (it runs as root, sochmod 0o000doesn't block the read).coverage report— exit 0, TOTAL 100.00% (54333 statements / 0 missed, 4258 branches / 0 partial; the repo gate is branch coveragefail_under = 100).strict-no-cover— clean. Lint:ruff check+ruff format --checkclean 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).