Skip to content

Cached-credential reuse via ClientOAuthOptions.ClientId fails: no way to specify token_endpoint_auth_method #1611

@mukunku

Description

@mukunku

Summary

When an MCP client persists the client_id / client_secret returned from Dynamic Client Registration and then provides them via ClientOAuthOptions.ClientId / ClientSecret on a subsequent session (to skip DCR), the token exchange step fails with 401 Unauthorized against authorization servers whose token_endpoint_auth_methods_supported does not list "client_secret_post" first.

ClientOAuthOptions has no TokenEndpointAuthMethod setting, so there's no supported way to tell the SDK which method the client was registered with — the SDK falls back to the AS metadata's first supported method, which may not match how the client was registered.

Prior discussion

This failure mode was originally flagged as conformance test auth/pre-registration in #1177, which was closed by #1254 ("Bring up to 0.1.13 conformance"). PR #1254 introduced ClientOAuthOptions.ClientId / ClientSecret, but the matching TokenEndpointAuthMethod was not exposed — so reusing DCR-acquired credentials still fails at token exchange on ASes that don't list client_secret_post first in their metadata. Reproducible on 1.3.0.

Reproduction

  1. Configure a client against an AS (e.g. WorkOS AuthKit) whose token_endpoint_auth_methods_supported lists none before client_secret_post:

    "token_endpoint_auth_methods_supported": ["none", "client_secret_post", "client_secret_basic"]
  2. First run — let the SDK do DCR:

    OAuth = new ClientOAuthOptions
    {
        RedirectUri = new Uri("http://localhost:5556/callback"),
        AuthorizationRedirectDelegate = ...,
        DynamicClientRegistration = new DynamicClientRegistrationOptions
        {
            ClientName = "Sample MCP Client",
            ResponseDelegate = (response, ct) =>
            {
                // persist response.ClientId and response.ClientSecret to disk
                return Task.CompletedTask;
            },
        },
    }

    DCR succeeds, MCP initialize succeeds, tool calls succeed. ✅

  3. Second run — read cached credentials from disk and pass them back:

    OAuth = new ClientOAuthOptions
    {
        ClientId = cachedClientId,
        ClientSecret = cachedClientSecret,
        RedirectUri = new Uri("http://localhost:5556/callback"),
        AuthorizationRedirectDelegate = ...,
        // No DynamicClientRegistration — we want to skip DCR
    }

    Token exchange fails:

    System.Net.Http.HttpRequestException: Response status code does not indicate success: 401 (Unauthorized).
    Response body: {"error":"unauthorized"}
    

Root cause

In ClientOAuthProvider.cs:655, PerformDynamicClientRegistrationAsync hardcodes the DCR request's TokenEndpointAuthMethod:

var registrationRequest = new DynamicClientRegistrationRequest
{
    // ...
    TokenEndpointAuthMethod = "client_secret_post",
    // ...
};

So every DCR'd client is registered as client_secret_post (confidential). The provider stores this on its private _tokenEndpointAuthMethod field when DCR completes — but only in memory for the current session.

On a subsequent session where ClientOAuthOptions.ClientId is set (skipping DCR), the provider's _tokenEndpointAuthMethod starts null and falls back via:

_tokenEndpointAuthMethod ??= authServerMetadata.TokenEndpointAuthMethodsSupported?.FirstOrDefault();

For an AS that lists none first (e.g. WorkOS AuthKit), the provider selects "none". CreateTokenRequest then takes the none branch:

else if (string.Equals(_tokenEndpointAuthMethod, "none", StringComparison.Ordinal))
{
    // Public client: include client_id in the body but no secret.
    formFields["client_id"] = clientId;
}

…and sends client_id only — no client_secret. The AS rejects with 401 because the registered client expects client_secret_post. The cached ClientSecret we worked to capture is never used.

Expected behavior

Reusing a DCR'd client via cached credentials should work without falling out of the supported configuration surface. Either:

  • The SDK persists / accepts the auth method alongside ClientId / ClientSecret, or
  • The DCR step doesn't hardcode client_secret_post (e.g., negotiates or defaults to none for PKCE-only flows).

Suggested fix

Add TokenEndpointAuthMethod to ClientOAuthOptions:

public sealed class ClientOAuthOptions
{
    // ...existing properties...

    /// <summary>
    /// Gets or sets the token endpoint authentication method to use when <see cref="ClientId"/>
    /// is supplied externally (e.g. from a persisted DCR registration). When omitted, the SDK
    /// falls back to the authorization server's metadata.
    /// </summary>
    public string? TokenEndpointAuthMethod { get; set; }
}

Initialize ClientOAuthProvider._tokenEndpointAuthMethod from this option in the constructor. The existing fallback to AS metadata stays as a default.

Environment

  • SDK: ModelContextProtocol / ModelContextProtocol.AspNetCore 1.3.0
  • Authorization server: WorkOS AuthKit
  • .NET 10.0

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions