Skip to content

Conversation

@pcarleton
Copy link
Member

@pcarleton pcarleton commented Nov 24, 2025

Summary

Implements SEP-1046 OAuth client_credentials flow support with simplified providers for machine-to-machine authentication.

New OAuth Providers

  • ClientCredentialsOAuthProvider: For client_credentials grant with client_id + client_secret

    • Supports client_secret_basic (default) and client_secret_post auth methods
    • Sets client_info directly, bypassing dynamic client registration
  • PrivateKeyJWTOAuthProvider: For client_credentials grant with private_key_jwt authentication (RFC 7523 Section 2.2)

    • Takes an assertion_provider callback that receives the audience (authorization server's issuer identifier per RFC 7523bis) and returns a JWT
    • Designed for workload identity federation (GCP, AWS IAM, Azure AD)
  • SignedJWTParameters: Helper class for SDK-signed JWT assertions

    • create_assertion_provider() returns a callback for use with PrivateKeyJWTOAuthProvider
  • static_assertion_provider(): Helper for pre-built JWTs that don't need the audience parameter

Deprecation

RFC7523OAuthClientProvider is now deprecated with a DeprecationWarning.

The original implementation incorrectly used RFC 7523 Section 2.1 (jwt-bearer authorization grant where the JWT itself is the authorization) instead of the intended Section 2.2 (private_key_jwt client authentication with grant_type=client_credentials).

Use ClientCredentialsOAuthProvider or PrivateKeyJWTOAuthProvider instead.

Example Usage

# Simple client credentials with client_id + secret
provider = ClientCredentialsOAuthProvider(
    server_url="https://api.example.com",
    storage=my_token_storage,
    client_id="my-client-id",
    client_secret="my-client-secret",
)

# Private key JWT with workload identity federation
async def get_workload_identity_token(audience: str) -> str:
    return await fetch_token_from_identity_provider(audience=audience)

provider = PrivateKeyJWTOAuthProvider(
    server_url="https://api.example.com",
    storage=my_token_storage,
    client_id="my-client-id",
    assertion_provider=get_workload_identity_token,
)

# Private key JWT with SDK-signed assertions
jwt_params = SignedJWTParameters(
    issuer="my-client-id",
    subject="my-client-id",
    signing_key=private_key_pem,
)
provider = PrivateKeyJWTOAuthProvider(
    server_url="https://api.example.com",
    storage=my_token_storage,
    client_id="my-client-id",
    assertion_provider=jwt_params.create_assertion_provider(),
)

Testing

  • Unit tests with 100% coverage for new providers
  • Conformance tests pending

New OAuth providers for machine-to-machine authentication:

- ClientCredentialsOAuthProvider: For client_credentials with client_id + client_secret
- PrivateKeyJWTOAuthProvider: For client_credentials with private_key_jwt (RFC 7523 Section 2.2)
- SignedJWTParameters: Helper class for SDK-signed JWT assertions
- static_assertion_provider(): Helper for pre-built JWTs from workload identity federation

The new providers set client_info directly in constructor, bypassing dynamic
client registration which isn't needed for pre-registered machine clients.

Deprecate RFC7523OAuthClientProvider: The original implementation incorrectly
used RFC 7523 Section 2.1 (jwt-bearer authorization grant) instead of the
intended Section 2.2 (private_key_jwt client authentication with
grant_type=client_credentials).

Also skip 3 flaky timing-dependent tests in test_stdio.py.
@pcarleton pcarleton force-pushed the pcarleton/client-credentials-jwt branch from b9dae14 to b007b28 Compare November 25, 2025 17:14
pcarleton and others added 4 commits November 25, 2025 17:57
The base class _initialize() loads client_info from storage, which
overwrites any value set in the constructor. Move client_info setup
to _initialize override so it's properly set after tokens are loaded.

Also update tests to call _initialize() before checking client_info.
- Add missing blank line after function definition (ruff-format)
- Add pragma: no cover to mock function not executed in test

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Add explicit None checks before accessing client_info attributes
to satisfy pyright type narrowing requirements.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Remove pytest.mark.skip decorators that were accidentally added,
restoring the file to match main branch.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@pcarleton pcarleton marked this pull request as ready for review November 25, 2025 20:39
@pcarleton pcarleton requested a review from maxisbey November 25, 2025 20:40
Copy link
Contributor

@felixweinberger felixweinberger left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a comment regarding something that's pre-existing, otherwise LGTM

headers: dict[str, str] = {"Content-Type": "application/x-www-form-urlencoded"}

# Use standard auth methods (client_secret_basic, client_secret_post, none)
token_data, headers = self.context.prepare_token_auth(token_data, headers)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not an issue with this PR, but something pre-existing @claude pointed out here as a discrepancy between TS & Python:

prepare_token_auth doesn't include client_id for the client_secret_post auth method. According to RFC 6749 both _id and _secret should be in the request body for this method.

TS does that as it does different things for applyBasicAuth and applyPostAuth: https://github.com/modelcontextprotocol/typescript-sdk/blob/de41e4703c937753de5299b11255656efae59bc4/src/client/auth.ts#L310

While Python seems to use prepare_token_auth in both cases.

Might be worth a follow-up?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

huh... yea we should fix that! i've got a draft conformance test for it here which should catch this:
modelcontextprotocol/conformance#48

the client credentials one uses client_secret_basic which would be why this didn't catch it

@pcarleton pcarleton merged commit f82b0c9 into main Dec 2, 2025
55 of 57 checks passed
@pcarleton pcarleton deleted the pcarleton/client-credentials-jwt branch December 2, 2025 12:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants