Skip to content

MCP OAuth2: Unconditional client_id in token request body breaks client_secret_basic with Slack and similar providers #4782

Closed

Description

@joar

🔴 Required Information

Please ensure all items in this section are completed to allow for efficient triaging. Requests without complete information may be rejected / deprioritized. If an item is not applicable to you - please mark it as N/A

Describe the Bug:
When using OAuth2 with client_secret_basic (HTTP Basic Auth at the token endpoint), ADK unconditionally includes client_id in the token request body. Per RFC 6749 §4.1.3, client_id in the body is only REQUIRED "if the client is not authenticating with the authorization server as described in Section 3.2.1." When the client is authenticating via HTTP Basic (Section 3.2.1), sending client_id in the body is unnecessary and causes interoperability failures with providers (e.g. Slack) that infer auth method from the request: if they see client_id in the body, they treat the request as body-based auth and ignore the HTTP Basic Auth header, leading to "bad client auth."

Steps to Reproduce:

  1. Install google-adk (version that includes the fix from PR fix(oauth): add client id to token exchange #2805, e.g. 1.26.0 or later).
  2. Configure an MCP toolset with OAuth2 using Slack as the provider (or any provider that treats presence of client_id in body as "authenticate via body" and ignores Basic auth).
  3. Use OAuth2Auth with client_id and client_secret and do not set token_endpoint_auth_method (so ADK uses default client_secret_basic).
  4. Complete the authorization code flow; when ADK exchanges the code for a token, the token endpoint returns an error such as "bad client auth" or similar.

Expected Behavior:
When using client_secret_basic, the token request should authenticate only via the Authorization: Basic ... header (per RFC 6749 §3.2.1) and should not include client_id in the request body, so that providers that key off body parameters do not misinterpret the auth method. Token exchange should succeed.

Observed Behavior:
ADK sends both HTTP Basic Auth and client_id in the token request body. The provider (e.g. Slack) sees client_id in the body, assumes body-based client auth, ignores the Basic header, and rejects the request with "bad client auth" (or equivalent).

Environment Details:

Model Information:

  • Are you using LiteLLM: N/A
  • Which model is being used: N/A

🟡 Optional Information

Providing this information greatly speeds up the resolution process.

Regression:
Yes. This regressed after the merge of PR #2805 ("fix(oauth): add client id to token exchange"), which added client_id to the token request body to address issue #2806. That change was based on a misreading of RFC 6749 §4.1.3: the RFC states that client_id is REQUIRED only "if the client is not authenticating with the authorization server as described in Section 3.2.1". When the client is authenticating via Section 3.2.1 (e.g. HTTP Basic), client_id in the body is not required and should be omitted to avoid breaking providers that infer auth method from the presence of body parameters.

Logs:

// Token exchange fails with 400 or similar; provider returns error like "bad client auth"
// or "invalid_client" when client_secret_basic is used and client_id is present in body.

Additional Context:

  • RFC reference: RFC 6749 §4.1.3 (Access Token Request) explicitly says: "client_id - REQUIRED, if the client is not authenticating with the authorization server as described in Section 3.2.1."
  • Suggested fix: Include client_id (and, for post auth, client_secret) in the token request body only when:
    • the client is not authenticating via Section 3.2.1 (e.g. public client), or
    • token_endpoint_auth_method is client_secret_post.
      When token_endpoint_auth_method is client_secret_basic (or equivalent), do not add client_id to the body.

Minimal Reproduction Code:

from google.adk.auth import AuthCredential, AuthCredentialTypes, OAuth2Auth
from google.adk.auth.auth_schemes import OpenIdConnectWithConfig
from google.adk.tools.mcp_tool import McpToolset, StreamableHTTPConnectionParams

# Slack (or similar) OAuth2 with default auth = client_secret_basic.
# ADK sends client_id in body + Basic header; Slack ignores Basic, expects body credentials → "bad client auth".
McpToolset(
    connection_params=StreamableHTTPConnectionParams(url="https://mcp.slack.com/mcp"),
    auth_scheme=OpenIdConnectWithConfig(
        authorization_endpoint=settings.SLACK_MCP.OAUTH2.AUTHORIZATION_URL,
        token_endpoint=settings.SLACK_MCP.OAUTH2.TOKEN_URL,
        scopes=settings.SLACK_MCP.OAUTH2.SCOPES,
    ),
    auth_credential=AuthCredential(
        auth_type=AuthCredentialTypes.OAUTH2,
        oauth2=OAuth2Auth(
            client_id=settings.SLACK_MCP.OAUTH2.CLIENT_ID,
            client_secret=settings.SLACK_MCP.OAUTH2.CLIENT_SECRET,
            # Workaround: force body-based auth so server and client agree.
            # token_endpoint_auth_method="client_secret_post",
        ),
    ),
)

Workaround: Set token_endpoint_auth_method="client_secret_post" on OAuth2Auth so credentials are sent in the body; then the provider’s expectation (body-based auth) matches what ADK sends.

How often has this issue occurred?:

  • Always (100%) when using client_secret_basic with Slack (and likely other providers that infer auth from body params).

Activity

  1. added
    auth[Component] This issue is related to authorization
    on Mar 10, 2026
  2. added theissue type on Mar 10, 2026
  3. weiguangli-io commented on Mar 11, 2026

    @weiguangli-io
    Contributor

    Root Cause Analysis

    The bug is in src/google/adk/auth/exchanger/oauth2_credential_exchanger.py, specifically in the _exchange_authorization_code method at line 203:

    tokens = client.fetch_token(
        token_endpoint,
        authorization_response=self._normalize_auth_uri(
            auth_credential.oauth2.auth_response_uri
        ),
        code=auth_credential.oauth2.auth_code,
        grant_type=OAuthGrantType.AUTHORIZATION_CODE,
        client_id=auth_credential.oauth2.client_id,  # <-- unconditional
    )

    client_id is passed as a keyword argument to authlib's fetch_token(), which adds it to the POST body unconditionally. Meanwhile, the OAuth2Session is already constructed (in oauth2_credential_util.py:88-95) with token_endpoint_auth_method=auth_credential.oauth2.token_endpoint_auth_method, which defaults to "client_secret_basic". So authlib sends both the Authorization: Basic ... header and client_id in the body.

    RFC 6749 §4.1.3 Violation

    Per RFC 6749 §4.1.3:

    client_id — REQUIRED, if the client is not authenticating with the authorization server as described in Section 3.2.1.

    When client_secret_basic is used, the client is authenticating per §3.2.1 (HTTP Basic), so client_id must not be in the body. Providers like Slack that infer auth method from the presence of body parameters will see client_id, treat the request as body-based auth, ignore the Basic header, and reject with "bad client auth."

    Proposed Fix

    Conditionally include client_id in the fetch_token() call based on token_endpoint_auth_method:

    async def _exchange_authorization_code(self, auth_credential, auth_scheme):
        client, token_endpoint = create_oauth2_session(auth_scheme, auth_credential)
        if not client:
            logger.warning("Could not create OAuth2 session for authorization code exchange")
            return ExchangeResult(auth_credential, False)
    
        fetch_kwargs = dict(
            authorization_response=self._normalize_auth_uri(
                auth_credential.oauth2.auth_response_uri
            ),
            code=auth_credential.oauth2.auth_code,
            grant_type=OAuthGrantType.AUTHORIZATION_CODE,
        )
    
        # Per RFC 6749 §4.1.3, client_id in the body is only REQUIRED when
        # the client is NOT authenticating via §3.2.1 (e.g., public clients
        # or client_secret_post).  When using client_secret_basic, including
        # client_id in the body causes interop failures with providers that
        # infer auth method from body parameters.
        auth_method = auth_credential.oauth2.token_endpoint_auth_method
        if auth_method != "client_secret_basic":
            fetch_kwargs["client_id"] = auth_credential.oauth2.client_id
    
        try:
            tokens = client.fetch_token(token_endpoint, **fetch_kwargs)
            update_credential_with_tokens(auth_credential, tokens)
            logger.debug("Successfully exchanged authorization code for access token")
        except Exception as e:
            logger.error("Failed to exchange authorization code: %s", e)
            return ExchangeResult(auth_credential, False)
    
        return ExchangeResult(auth_credential, True)

    This keeps the fix from PR #2805 working for client_secret_post and public clients while avoiding the regression for client_secret_basic.


    I'd be happy to submit a PR with this fix if that would be helpful.

  4. joar commented on Mar 11, 2026

    @joar
    Author

    @weiguangli-io
    authlib already puts client_id in the request body when the auth method is either "none" or "client_secret_post" and I think that if there's any doubt whether authlib follows RFC 6749, then the fix should be submitted to authlib. See my comment with the original issue about supposedly missing client_id: #2806 (comment), which challenges the interpretation of the RFC in that ticket, and also argues that any fixing of RFC-noncompliance would belong in authlib, as authlib explicitly claims to be spec-compliant with RFC 6749.

    I think the burden of proof should be quite high when the claim is that authlib (which claims to be spec-compliant) isn't spec-compliant.

    Reading the description in issue #2806, it only links to one section of the RFC that in turn contains one sentence about client_id, and that sentence does not support the claim in the issue. The description does not mention a specific OAuth server that the author is trying and failing to authenticate with, so it's hard to know if the root cause was a non-compliant server, or if the author simply enjoys reading specifications, and made a misinterpretation of the spec.

  5. joar commented on Mar 11, 2026

    @joar
    Author

    @weiguangli-io additionally, passing client_id to client.fetch_token() would cause the field to be included twice in the request body when the method is "client_secret_post", i.e.,

    client_id=foo&client_id=foo&client_secret=bar
    └───────┬──┘  └─────────────┬──────────────┘
            │                   │
            │                   authlib's encode_client_secret_post 
            fetch_token kwarg
    
  6. saschabuehrle commented on Mar 11, 2026

    @saschabuehrle

    Thanks for the clear repro. This is a classic OAuth interoperability edge case.

    For client_secret_basic, many providers expect client id and client secret only in Authorization header and reject duplicated client id in body.

    A compatibility strategy that usually works:

    Respect token_endpoint_auth_method when provider metadata is available
    If method is client_secret_basic, omit client id from body
    If method is client_secret_post, include both in body
    If metadata is missing, try configured method first and avoid automatic fallback unless user enables it

    That last part prevents accidental auth method probing across providers. A small matrix test with Slack, Okta, and Auth0 token endpoints can catch regressions early.

  7. joar commented on Mar 11, 2026

    @joar
    Author

    @xuanyang15 (cc because you were involved in merging #2806)

    The reasoning in

    seems to be that client_id is REQUIRED in request body in order to comply with the spec.

    The spec says this:

    client_id
    REQUIRED, if the client is not authenticating with the authorization server as described in Section 3.2.1.

    — RFC 6749 §4.1.3

    and to an impaired, stressed or unlucky reader, it might look as if client_id is required always, but the spec doesn't say that. The spec says:

    client_id is only required when authentication as described in Section 3.2.1 is not used.

    this means that, even though I have no doubt the intentions were good, ADK is now out-of-spec, because we pass client_id in request body all the time, sometimes twice, even though it's not required.

    if we count client_id in request body as an authentication method (it's 50% of the client_secret_post authentication method), the spec says this:

    The client MUST NOT use more than one authentication method in each
    request.
    — RFC 6749 Section 2.3

    authlib already implements the spec, and passes client_id in request body when no other token endpoint authentication method is provided.

    In summary:

    1. ADK is seemingly out of spec, very likely due to a misinterpretation of the spec.
    2. There's no valid stated need for overriding authlib's functionality by passing client_id all the time.

    Passing client_id against authlib's advice would be an out-of-spec workaround for out-of-spec providers, and it can help, however, there's no claim that such a provider exists, as it wasn't mentioned in the original issue #2806 or PR #2805, so I will not bother with adding the workaround until we know it's useful - If you know of such a provider, feel free to add the appropriate workaround, just make sure it doesn't affect authlib's decision-making when the workaround isn't active.

  8. joar commented on Mar 11, 2026

    @joar
    Author

    @saschabuehrle What you're saying comes off as very general, high-level and unspecific to me. If you have any light to shed on what you're trying to say, please shed that light.

    Meanwhile, I'll attempt to figure out what you mean.

    With regards to

    if metadata is missing, try configured method first and avoid automatic fallback unless user enables it

    That last part prevents accidental auth method probing across providers. A small matrix test with Slack, Okta, and Auth0 token endpoints can catch regressions early.

    by "automatic fallback" and "auth method probing", are you

    1. referencing ADK's default value of client_secret_basic for token_endpoint_auth_method?
      token_endpoint_auth_method: Optional[
      Literal[
      "client_secret_basic",
      "client_secret_post",
      "client_secret_jwt",
      "private_key_jwt",
      ]
      ] = "client_secret_basic"
      I think that e.g., forgetting to set token_endpoint_auth_method to client_secret_post when your provider requires client_secret_post, counts as accidental misconfiguration rather than probing; or
    2. imagining a hypothetical scenario where someone has suggested implementing auth method probing to solve an issue that is at best adjacent to auth method probing? or
    3. some actual automatic fallback and auth method probing that I've missed while searching through ADK.
  9. ilyesJammouci commented on Mar 12, 2026

    @ilyesJammouci

    Hi maintainers
    I'm interested in contributing to this issue
    Before getting started, I wanted to confirm whether someone is already working on it. If not, I’d be happy to take it and open a PR
    Please let me know if this issue is still available. Thanks!

  10. xuanyang15 commented on Mar 12, 2026

    @xuanyang15
    Collaborator

    @ilyesJammouci Thank you! @GWeale Are you working on this?

  11. joar commented on Mar 12, 2026

    @joar
    Author

    In case it's been missed, there is a PR that fixes this issue at #4783

  12. added a commit that references this issue on Mar 16, 2026
    50d6f35
  13. davxiao commented on Mar 16, 2026

    @davxiao

    I believe this fix maybe the root cause for #4850 as well - it was Okta Oauth2

  14. added a commit that references this issue on Apr 9, 2026
    630d001
  15. added a commit that references this issue on Aug 11, 2026
    1b01e6b
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

auth[Component] This issue is related to authorization

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions