Skip to content

Regression (OAuth flow): Calling token endpoint does not honor "token_endpoint_auth_methods_supported" #726

Closed

Description

@shlomiuziel

Describe the bug
The OAuth flow always chooses client_secret_post regardless of the value declared in token_endpoint_auth_methods_supported and the presence of client_secret

To Reproduce
Commence OAuth flow with any authorization server that issues a client_id and client_secret via the client registration endpoint (DCR). The call to the token endpoint will default to client_secret_post regardless of the token_endpoint_auth_methods_supported value returned in "/.well-known/oauth-authorization-server" endpoint.

Here's a simple Flask based dummy OAuth server that replicates the issue (fails with npx @modelcontextprotocol/inspector@0.16.4, passes with npx @modelcontextprotocol/inspector@0.16.3:



"""
Simple dummy OAuth authorization server for testing purposes.
Provides basic OAuth 2.0 endpoints with minimal validation.
"""

import base64
import json
import logging
from datetime import datetime, timedelta
from typing import Dict, Any, Optional, Tuple
from urllib.parse import urlencode, parse_qs
from flask import Flask, request, jsonify, redirect, make_response

logger = logging.getLogger(__name__)

app = Flask(__name__)

# CORS configuration
@app.after_request
def after_request(response):
    response.headers['Access-Control-Allow-Origin'] = '*'
    response.headers['Access-Control-Allow-Headers'] = 'Content-Type,Authorization,Accept'
    response.headers['Access-Control-Allow-Methods'] = 'GET,POST,OPTIONS'
    response.headers['Access-Control-Allow-Credentials'] = 'true'
    return response

# Dummy client credentials (hardcoded for testing)
DUMMY_CLIENT_ID = "dummy_client_12345"
DUMMY_CLIENT_SECRET = "dummy_secret_abcdef123456"


def parse_client_credentials(auth_header: Optional[str] = None) -> Tuple[Optional[str], Optional[str]]:
    """
    Parse client credentials from Authorization header (Basic auth) or request body.
    Returns tuple of (client_id, client_secret) or (None, None) if not found.
    """
    client_id = None
    client_secret = None
    
    # Try Authorization header first (Basic auth)
    if auth_header and auth_header.startswith('Basic '):
        try:
            encoded_credentials = auth_header[6:]  # Remove 'Basic ' prefix
            decoded_credentials = base64.b64decode(encoded_credentials).decode('utf-8')
            client_id, client_secret = decoded_credentials.split(':', 1)
        except Exception as e:
            logger.warning(f"Failed to parse Basic auth header: {e}")
    
    # Fall back to request body if not in header
    if not client_id:
        content_type = request.headers.get('Content-Type', '')
        if 'application/x-www-form-urlencoded' in content_type:
            client_id = request.form.get('client_id')
            client_secret = request.form.get('client_secret')
        elif 'application/json' in content_type:
            json_data = request.get_json() if request.is_json else None
            if json_data:
                client_id = json_data.get('client_id')
                client_secret = json_data.get('client_secret')
    
    return client_id, client_secret


@app.route('/register', methods=['POST'])
def register():
    """
    Client registration endpoint.
    Returns consistent dummy client credentials.
    """
    try:
        data = request.get_json() or {}
        
        # Return the dummy client configuration
        dummy_config = {
            "client_id": DUMMY_CLIENT_ID,
            "client_secret": DUMMY_CLIENT_SECRET,
            "client_id_issued_at": int(datetime.utcnow().timestamp()),
            "client_secret_expires_at": 0,  # 0 = never expires (per spec)
            "redirect_uris": ["http://localhost:3000/callback", "http://localhost:8080/callback"],
            "token_endpoint_auth_method": "client_secret_basic",
            "grant_types": ["authorization_code"],
            "response_types": ["code"],
            "client_name": "Dummy OAuth Client",
            "client_uri": "http://localhost:3000",
            "scope": "read write admin",
        }
        
        print(f"[REGISTER] Dummy client registered: {DUMMY_CLIENT_ID}")
        print(f"[REGISTER] Client data: {json.dumps(dummy_config, indent=2)}")
        
        return jsonify(dummy_config)
        
    except Exception as e:
        logger.error(f"Registration error: {e}")
        return jsonify({'error': 'invalid_request', 'error_description': str(e)}), 400


@app.route('/.well-known/oauth-authorization-server', methods=['GET'])
def well_known_oauth():
    """
    OAuth 2.0 Authorization Server Metadata endpoint.
    Returns server capabilities and endpoint URLs.
    """
    base_url = request.host_url.rstrip('/')
    
    metadata = {
        'issuer': base_url,
        'authorization_endpoint': f'{base_url}/authorize',
        'token_endpoint': f'{base_url}/token',
        'registration_endpoint': f'{base_url}/register',
        'response_types_supported': ['code'],
        'grant_types_supported': ['authorization_code'],
        'token_endpoint_auth_methods_supported': ['client_secret_basic'],
        'scopes_supported': ['read', 'write', 'admin'],
        'code_challenge_methods_supported': ['S256', 'plain']
    }
    
    print(f"[WELL-KNOWN] Served metadata for issuer: {base_url}")
    return jsonify(metadata)


@app.route('/authorize', methods=['GET', 'POST'])
def authorize():
    """
    Authorization endpoint.
    Always returns successful authorization (dummy behavior).
    """
    try:
        # Extract parameters
        client_id = request.args.get('client_id') or request.form.get('client_id')
        redirect_uri = request.args.get('redirect_uri') or request.form.get('redirect_uri')
        response_type = request.args.get('response_type') or request.form.get('response_type', 'code')
        scope = request.args.get('scope') or request.form.get('scope', 'read')
        state = request.args.get('state') or request.form.get('state')
        code_challenge = request.args.get('code_challenge') or request.form.get('code_challenge')
        code_challenge_method = request.args.get('code_challenge_method') or request.form.get('code_challenge_method')
        
        print(f"[AUTHORIZE] Authorization request received:")
        print(f"[AUTHORIZE] client_id: {client_id}")
        print(f"[AUTHORIZE] redirect_uri: {redirect_uri}")
        print(f"[AUTHORIZE] response_type: {response_type}")
        print(f"[AUTHORIZE] scope: {scope}")
        print(f"[AUTHORIZE] state: {state}")
        print(f"[AUTHORIZE] code_challenge: {code_challenge}")
        print(f"[AUTHORIZE] code_challenge_method: {code_challenge_method}")
        
        if not client_id or not redirect_uri:
            return jsonify({'error': 'invalid_request', 'error_description': 'Missing required parameters'}), 400
        
        # Only support authorization code flow
        if response_type != 'code':
            return jsonify({'error': 'unsupported_response_type'}), 400
        
        # Generate dummy authorization code (always the same for simplicity)
        auth_code = "dummy_auth_code_12345"
        
        # Build redirect URL
        params = {'code': auth_code}
        if state:
            params['state'] = state
        
        redirect_url = f"{redirect_uri}?{urlencode(params)}"
        
        print(f"[AUTHORIZE] Authorization granted! Redirecting to: {redirect_url}")
        return redirect(redirect_url)
            
    except Exception as e:
        logger.error(f"Authorization error: {e}")
        return jsonify({'error': 'server_error', 'error_description': str(e)}), 500


@app.route('/token', methods=['POST'])
def token():
    """
    Token endpoint.
    Prints all received parameters and client credentials.
    """
    try:
        # Parse client credentials
        auth_header = request.headers.get('Authorization')
        client_id, client_secret = parse_client_credentials(auth_header)
        
        # Simple validation - just check if it matches our dummy credentials
        if client_id != DUMMY_CLIENT_ID or client_secret != DUMMY_CLIENT_SECRET:
            print(f"[TOKEN] Invalid client credentials: {client_id}")
            return jsonify({'error': 'invalid client credentials'}), 401
        
        # Parse request data based on content type
        content_type = request.headers.get('Content-Type', '')
        request_data = {}
        
        if 'application/x-www-form-urlencoded' in content_type:
            request_data = dict(request.form)
            print(f"[TOKEN] Processing application/x-www-form-urlencoded request")
        elif 'application/json' in content_type:
            try:
                request_data = request.get_json() or {}
                print(f"[TOKEN] Processing application/json request")
            except Exception as e:
                print(f"[TOKEN] Failed to parse JSON: {e}")
                request_data = {}
        else:
            print(f"[TOKEN] Unsupported content type: {content_type}")
            return jsonify({'error': 'invalid_request', 'error_description': 'Unsupported content type'}), 400
        
        print(f"\n[TOKEN] Token request received:")
        print(f"[TOKEN] Content-Type: {content_type}")
        print(f"[TOKEN] Authorization header: {auth_header}")
        print(f"[TOKEN] Parsed client_id: {client_id}")
        print(f"[TOKEN] Parsed client_secret: {client_secret}")
        print(f"[TOKEN] Request data: {json.dumps(request_data, indent=2)}")
        print(f"[TOKEN] All headers: {dict(request.headers)}")
        
        # Extract grant type and other parameters
        grant_type = request_data.get('grant_type')
        
        if grant_type != 'authorization_code':
            print(f"[TOKEN] Unsupported grant type: {grant_type}")
            return jsonify({'error': 'unsupported_grant_type'}), 400
        
        code = request_data.get('code')
        redirect_uri = request_data.get('redirect_uri')
        code_verifier = request_data.get('code_verifier')
        
        print(f"[TOKEN] Authorization code flow:")
        print(f"[TOKEN] code: {code}")
        print(f"[TOKEN] redirect_uri: {redirect_uri}")
        print(f"[TOKEN] code_verifier: {code_verifier}")
        
        # Always accept any authorization code (dummy server)
        print(f"[TOKEN] Accepting authorization code: {code}")
        
        # Generate dummy tokens (consistent for testing)
        access_token = "dummy_access_token_12345"
        refresh_token = "dummy_refresh_token_12345"
        
        token_response = {
            'access_token': access_token,
            'token_type': 'Bearer',
            'expires_in': 3600,
            'refresh_token': refresh_token,
            'scope': 'read write'
        }
        
        print(f"[TOKEN] Issuing tokens: {json.dumps(token_response, indent=2)}")
        logger.info(f"Token issued for client {client_id}")
        
        return jsonify(token_response)
        
    except Exception as e:
        logger.error(f"Token endpoint error: {e}")
        print(f"[TOKEN] Error: {e}")
        return jsonify({'error': 'server_error', 'error_description': str(e)}), 500


if __name__ == '__main__':
    logging.basicConfig(level=logging.INFO)
    print("Starting minimal OAuth authorization server...")
    print("Available endpoints:")
    print("  POST /register - Client registration")
    print("  GET  /.well-known/oauth-authorization-server - Server metadata")
    print("  GET/POST /authorize - Authorization endpoint (code flow only)")
    print("  POST /token - Token endpoint")
    print(f"  Dummy client_id: {DUMMY_CLIENT_ID}")
    print(f"  Dummy client_secret: {DUMMY_CLIENT_SECRET}")
    app.run(host='0.0.0.0', port=8081, debug=True)


Expected behavior
For "token_endpoint_auth_methods_supported": ["client_secret_basic"] , the token endpoint should be called with Basic authorization header using the client_id and client_secret.
(It's also the preferred default method in the absence of an explicit token_endpoint_auth_methods_supported - See https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1 )

Additional context
The regression was most likely introduced by : #715

Since the client_secret is not stored in the local storage anymore, it is not present in the clientInformation passed to selectClientAuthMethod and hasClientSecret evaluates to false, never choosing client_secret_basic.

Recommendations
To avoid storing and encrypting the client_secret altogether, we could add an option in the OAuth flow to either trigger the DCR registration endpoint every time without caching client_id and client_secret, or receive those credentials from a user text input.

Activity

  1. olaservo commented on Aug 20, 2025

    @olaservo
    Member

    Adding the link to the related PR here since the link in the issue description got malformed: #715

  2. cliffhall commented on Aug 20, 2025

    @cliffhall
    Member

    @olaservo I ran the Flask server from this issue's description and verified that:

    Both failures happen at the Token request step:

    Image
    • It appears the request to the token endpoint was application/x-www-form-urlencoded and that's why the server is croaking.
    Image

    I'm not certain how the changes in #175 actually cause this failure. I was expecting a failure due to the client secret not being sent. But instead it appears that it's because of the shape of the payload.

  3. olaservo commented on Aug 21, 2025

    @olaservo
    Member

    @cliffhall ok interesting, thanks for testing it. I'll set my PR back to a draft or close it for now while we continue to investigate this one.

  4. shlomiuziel commented on Aug 21, 2025

    @shlomiuziel
    Author

    @cliffhall @olaservo Thank you very much for looking into this!
    The exception you encountered @cliffhall is a bit misleading, I updated the demo to reflect that and the root cause still remains - the /token endpoint fails because of a missing client_secret, and that's the relationship to #715

  5. cliffhall commented on Aug 21, 2025

    @cliffhall
    Member

    @cliffhall @olaservo Thank you very much for looking into this! The exception you encountered @cliffhall is a bit misleading, I updated the demo to reflect that and the root cause still remains - the /token endpoint fails because of a missing client_secret, and that's the relationship to #715

    Thanks @shlomiuziel

    Tried the updated server and I see that I get back {"error": "Invalid client credentials"} and that the goal posts have moved a bit since the last time.

    In my testing with the previous incarnation I was seeing this in the AS metadata...

    "token_endpoint_auth_methods_supported": [
       "client_secret_post"
    ]

    Now it is...

    "token_endpoint_auth_methods_supported": [
       "client_secret_basic"
    ]

    All well and good, it should be able to handle all valid challenges.

    @felixweinberger @pcarleton Could you have a look? Run the above server in the description and connect Inspector 0.16.3 and 0.16.4 to it. Definitely the latter fails while the former doesn't. I do not see the expected Authorization request header in the /token request with client_id and client_secret encoded.

  6. shlomiuziel commented on Aug 22, 2025

    @shlomiuziel
    Author

    @cliffhall Thanks, I'm not sure though why you received ⁠ client_secret_post , because the auth server metadata hasn't changed in any recent edit.

    The core issue was always a missing ⁠ client_secret ⁠ , with the reproduction scenario describing the mismatch between the token endpoint auth method declared in the auth server metadata (⁠ client_secret_basic ⁠) vs the actual chosen method (⁠ client_secret_post ⁠) - because the client_secret fetched from the register endpoint was dropped.

  7. tmfahey commented on Aug 28, 2025

    @tmfahey

    Not only is token_endpoint_auth_methods_supported not respected, but in MCP Inspector v0.16.5 and v0.16.4 client_secret is not being provided alongside client_id in the token request.
    v0.16.3 works, but always uses  client_secret_basic ⁠regardless of what token_endpoint_auth_methods_supported is set to.
    A regression has definitely occurred with the release of 0.16.4

  8. davidthing commented on Sep 18, 2025

    @davidthing

    My testing confirms @tmfahey and @cliffhall's findings. 0.16.3 is the last version where the oauth was working.

    • In 0.15.0, MCP Inspector always uses client_secret_post, and the client_secret is set correctly in the body.
    • In 0.16.3, MCP Inspector always uses client_secret_basic, and the Authorization header is set correctly.
    • In 0.16.4 through 0.16.7, MCP Inspector always uses client_secret_basic, but the Authorization header is not set.

    In my testing, I have never seen token_endpoint_auth_methods_supported be respected.

  9. cliffhall commented on Sep 19, 2025

    @cliffhall
    Member

    My testing confirms @tmfahey and @cliffhall's findings. 0.16.3 is the last version where the oauth was working.

    • In 0.15.0, MCP Inspector always uses client_secret_post, and the client_secret is set correctly in the body.
    • In 0.16.3, MCP Inspector always uses client_secret_basic, and the Authorization header is set correctly.
    • In 0.16.4 through 0.16.7, MCP Inspector always uses client_secret_basic, but the Authorization header is not set.

    In my testing, I have never seen token_endpoint_auth_methods_supported be respected.

    It will bear some research, because each of these Inspector versions also may have SDK upgrades to which some of this behavior may be attributed. Most of the auth behavior should be encapsulated in the SDK, so that clients aren't all having to figure it out themselves.

    Let's go ahead and do some of this research here for posterity:

    • The terms client_secret_basic and client_secret_post do not appear anywhere in the Inspector codebase.
    • The method is selected in the SDK's selectClientAuthMethod function, based on the supported methods and whether a client secret is present.
    • selectClientAuthMethod is not called from anywhere in the Inspector codebase
    • selectClientAuthMethod is called from exchangeAuthorization and refreshAuthorization in the SDK.
    • In both cases, the supportedMethods argument of selectClientAuthMethod is drawn from metadata.token_endpoint_auth_methods_supported (see title of this issue).
    • The Inspector's oauth-state-machine calls exchangeAuthorization during the token_request step.
    • The metadata it passes was fetched in the metadata_discovery step and placed in local storage.
  10. cliffhall commented on Sep 19, 2025

    @cliffhall
    Member

    @tmfahey testing your Flask server above, I see it appears that the client secret is not passed in the client_secret_basic token request, and thus that request is failing.

    Image Image

    I suspect it could have been lost because of a change that was made to keep from placing sensitive information in localstorage. Specifically we filtered out the client_secret. If we are storing it in a previous step and later retrieving it for use, that could be the problem. In the above research I did see that we were saving the resource metadata and retrieving it at a later step. I will investigate this now.

  11. cliffhall commented on Sep 19, 2025

    @cliffhall
    Member

    Can confirm that if I don't filter out client_secret that the token request succeeds.
    Image

  12. cliffhall commented on Sep 22, 2025

    @cliffhall
    Member

    @shlomiuziel So...

    If we store the client_secret we can retrieve it again after the redirect and send it along to the /token request endpoint, making your example AS above happy. But we can't do that without encrypting it. The reason it was removed from storage previously was that CodeQL marked it as storing sensitive data, and the justification was that we weren't using it again after the redirect.

    This was slightly confusing, since we clearly needed it to send in the token request to your server after the redirect. Soon, we will see that we never should have used a client secret at any stage.

    I spent a day this weekend working out a scheme to encrypt the client_secret so it could be somewhat safely stored across the redirect boundary. While it satisfies this server, if the Inspector were ever hosted, and an XSS attack were able to exfiltrate the client id and secret from local storage, it could impersonate the client and get tokens from the AS outside our app. And if that exfiltrating code were able to run in the browser, it could also hit our proxy to get the key (which I was purposely not storing in local storage). So that's why I say somewhat safely, really not at all, just a little more difficult.

    A little more research and comprehension of this problem has revealed that public clients cannot be expected to keep secrets at all and so a server accepting connections from a public client MUST implement PKCE and accept client id and code challenge/verifier at the token request call. Thus, you should not expect it to honor a token_endpoint_auth_methods_supported setting of anything but none, since client_secret_basic and client_secret_post cannot not apply to a public client.

    Image Image Image
  13. davidthing commented on Sep 23, 2025

    @davidthing

    @cliffhall, do I understand correctly that MCP Inspector has dropped support for confidential clients? That is, it only supports a token_endpoint_auth_method/token_endpoint_auth_methods_supported of none? The way you phrased it implied that the servers are doing something wrong, but it's the MCP Inspector that is failing to honor the server's declared auth methods:

    public clients cannot be expected to keep secrets at all and so a server accepting connections from a public client MUST implement PKCE and accept client id and code challenge/verifier at the token request call. Thus, you should not expect it to honor a token_endpoint_auth_methods_supported setting of anything but none, since client_secret_basic and client_secret_post cannot not apply to a public client.

    The root problem seems to be that MCP Inspector previously acted like a confidential client, but it did not store the client secret on the server. Instead of fixing that — which was possible, since MCP Inspector does have a server-side component — MCP Inspector has dropped support for confidential clients. This leaves a few unsolved problems:

    1. MCP Inspector cannot test MCP Servers that support only confidential clients (which were previously supported)
    2. MCP Inspector (because of 1) encourages server authors to support an auth flow that is less secure
    3. MCP Inspector ignores the server's declared token_endpoint_auth_methods_supported
    4. MCP Inspector ignores the registered client's token_endpoint_auth_method
    5. MCP Inspector does not indicate to the user — through documentation or a client-side notification — that only an auth method of none is supported
    6. MCP Inspector's error when the token request fails does not indicate why it failed (the inspector attempted to perform a public client auth flow for a confidential client)

    @cliffhall, do you recommend that folks open new GH issues for those problems?

    This screenshot below shows how MCP Inspector (0.16.8) indicates that the metadata and client registration steps completed successfully, even though the responses in steps 1 and 2 provide enough information to MCP Inspector that the oauth flow cannot succeed. When the token request fails, the error information is blank:

    Image
  14. tmfahey commented on Sep 24, 2025

    @tmfahey

    I agree with @davidthing - it really does look like support for confidential clients that previously worked has now been dropped. If that’s the direction Inspector is going, there are still a lot of other unsolved problems that need to be addressed. Right now the tool doesn’t respect token_endpoint_auth_methods_supported, it ignores the registered client’s declared token_endpoint_auth_method, and when the token request fails the user isn’t given any useful error information.

    Inspector is in a unique spot. It runs like a public client, but its job is to test private/confidential MCP servers. Many of those expect client_secret_basic or client_secret_post. Without support for those flows, Inspector can’t fully exercise the OAuth configurations it’s meant to validate.

    I understand the concern about storing secrets, but for a developer tool it feels like a reasonable tradeoff. If the client_secret were stored encrypted only for the duration of the OAuth flow under test, developers could test confidential servers without leaving sensitive data lying around.

    If confidential clients really won’t be supported going forward, then the docs and UI need to make that clear - otherwise people will just keep running into these confusing failures.

  15. pcarleton commented on Sep 24, 2025

    @pcarleton
    Member

    I've put up a PR to restore the original behavior w/ a longer write-up of pro's and cons, see #827

  16. cliffhall commented on Sep 24, 2025

    @cliffhall
    Member

    The root problem seems to be that MCP Inspector previously acted like a confidential client, but it did not store the client secret on the server. Instead of fixing that — which was possible, since MCP Inspector does have a server-side component — MCP Inspector has dropped support for confidential clients. This leaves a few unsolved problems:

    @davidthing In light of this pushback, I took this back to the team for a re-evaluation. We constantly have pressure from a security perspective to get things right. But we are also a development tool that is meant to help server developers get things right. This was one of those places where the two were at odds. The decision was to go back to the way things were and from there, work toward a more secure stance with a server-stored secret.

    To really nail that, it will likely mean a more complex setup with the server handling the whole redirect and token request process. The proposed solution where I kept the key on the server would be hackable and really only a speed bump, so an XSS attack would just as easily be able to request the secret from the server as well. We'll see where this road leads us.

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

    authIssues and PRs related to authorizationbugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions