Skip to content

feat: Add Enterprise Managed Authorization (SEP-990) support#607

Open
prachi-okta wants to merge 5 commits intomodelcontextprotocol:mainfrom
prachi-okta:feature/enterprise-managed-authorization
Open

feat: Add Enterprise Managed Authorization (SEP-990) support#607
prachi-okta wants to merge 5 commits intomodelcontextprotocol:mainfrom
prachi-okta:feature/enterprise-managed-authorization

Conversation

@prachi-okta
Copy link

Implements Enterprise Managed Authorization (SEP-990) for the Kotlin MCP SDK

Enables MCP clients to leverage enterprise Identity Providers for seamless authorization without per-server user authentication.

Motivation and Context

Enterprise environments require OAuth flows where users authenticate with a centralized IdP, and applications need to securely access protected MCP resources on their behalf. SEP-990 addresses this via:

  • Token Exchange (RFC 8693): Exchanges a user's ID token from an enterprise IdP for a JWT Authorization Grant (ID-JAG)
  • JWT Bearer Grant (RFC 7523): Exchanges the ID-JAG for an access token at the MCP authorization server
  • OAuth Discovery (RFC 8414): Automatically discovers authorization server metadata for both IdP and MCP servers

This follows the same provider pattern as existing auth implementations and is consistent with the Java, TypeScript, C#, and Go SDK implementations. The provider is implemented as a Ktor HttpClientPlugin that intercepts outgoing requests via HttpSend, making it a first-class citizen of the Ktor client ecosystem.

How Has This Been Tested?

Added 32 unit tests across two test classes using Kotlin Multiplatform (commonTest), Kotest assertions, and Ktor MockEngine:

EnterpriseAuthTest (20 tests):

  • Authorization server metadata discovery — success via oauth-authorization-server, fallback to openid-configuration on 404/500, both-fail error, trailing slash stripping
  • JAG token exchange — success, optional params in body, wrong issued_token_type throws, non-standard token_type succeeds (informational per RFC 8693 §2.2.1), missing access_token throws, HTTP error throws
  • discoverAndRequestJwtAuthorizationGrant — full discovery flow, skips discovery when idpTokenEndpoint provided, no token_endpoint in metadata throws
  • JWT bearer grant — success with expiresAt computed from monotonic clock, missing access_token throws, HTTP error throws
  • isExpired — null expiresAt, future expiresAt, past expiresAt

EnterpriseAuthProviderTest (12 tests):

  • End-to-end Authorization: Bearer header injection via Ktor plugin
  • Token caching across multiple requests
  • invalidateCache forces re-fetch on next request
  • Discovery failure and assertion callback error propagation
  • Correct resourceUrl and authorizationServerUrl passed to callback
  • prepare validation — clientId null throws, assertionCallback null throws
  • Blank assertion callback return throws IllegalArgumentException
  • Token without expires_in cached indefinitely
  • Custom expiryBuffer respected
  • Full end-to-end flow — header set and token reused

Breaking Changes

None — this is a purely additive feature. No existing APIs are modified.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally (./gradlew :kotlin-sdk-client:jvmTest)
  • ./gradlew apiCheck passes
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

Key design decisions:

  • Ktor plugin patternEnterpriseAuthProvider implements HttpClientPlugin<Config, EnterpriseAuthProvider>, installed via install(EnterpriseAuthProvider) { ... } DSL; uses HttpSend interceptor for transparent header injection
  • Assertion callback pattern decouples IdP interaction from the provider — callers can implement JAG caching inside the callback to reduce IdP round-trips
  • client_secret_basic (Basic Auth header) used for JWT bearer grant, aligned with SEP-990 conformance requirements (RFC 6749 §2.3.1)
  • token_type in JAG response is not strictly validated per RFC 8693 §2.2.1 — it is informational and strict checking rejects conformant IdPs
  • Refresh tokens returned by the MCP AS are intentionally ignored — RFC 7523 is a stateless grant; using a refresh token would bypass IdP session/revocation policies
  • Monotonic clock (TimeSource.Monotonic) used for token expiry to avoid wall-clock skew issues
  • Double-checked locking with Mutex for thread-safe token caching on coroutines

Related SDK implementations:

@prachi-okta prachi-okta changed the title Feature/enterprise managed authorization feat: Add Enterprise Managed Authorization (SEP-990) support Mar 17, 2026
Copy link
Contributor

@kpavlov kpavlov left a comment

Choose a reason for hiding this comment

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

Thank you, @prachi-okta

I see this plugin is pretty generic and does not have any MCP-specifics. Not sure if it should belong to this project.
Did you consider submitting a PR to the Ktor project instead?

cc: @e5l

@kpavlov kpavlov added the question Further information is requested label Mar 17, 2026
@codecov-commenter
Copy link

codecov-commenter commented Mar 17, 2026

@prachi-okta
Copy link
Author

prachi-okta commented Mar 18, 2026

Thank you, @prachi-okta

I see this plugin is pretty generic and does not have any MCP-specifics. Not sure if it should belong to this project. Did you consider submitting a PR to the Ktor project instead?

cc: @e5l

Thanks for the thoughtful question!

While EnterpriseAuthProvider uses Ktor's plugin conventions, the substance is entirely MCP/SEP-990 specific. Here's the exact flow it implements:

① Discovery (RFC 8414)
MCP Client → MCP Auth Server — GET /.well-known/oauth-authorization-server
MCP Auth Server → MCP Client — { token_endpoint, issuer }

② JAG acquisition (RFC 8693) — delegated to assertionCallback
MCP Client → Enterprise IdP — token exchange with subject_token = OIDC ID Token, requested_token_type = id-jag
Enterprise IdP → MCP Client — ID-JAG

③ Access token exchange (RFC 7523)
MCP Client → MCP Auth Server — JWT bearer grant with assertion = ID-JAG, Authorization: Basic (client_secret_basic)
MCP Auth Server → MCP Client — { access_token, expires_in }

④ Authenticated MCP request
MCP Client → MCP Server — original request with Authorization: Bearer <access_token>

Steps ①③④ are handled internally by EnterpriseAuthProvider; step ② is delegated to the assertionCallback so callers can plug in their own IdP client. The token is cached with a configurable expiry buffer for proactive refresh.

None of this — ID-JAG token types, RFC 9728 resource discovery, the assertion callback contract — belongs in a general-purpose Ktor plugin. It's MCP enterprise auth logic all the way down, which is why it lives here. This also mirrors how the TypeScript SDK handled it: CrossAppAccessProvider lives in @modelcontextprotocol/client (merged in typescript-sdk#1593 just a few days ago) for the same reasons.

Happy to discuss further if you'd like! 😊

Copy link
Contributor

@devcrocod devcrocod left a comment

Choose a reason for hiding this comment

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

Auth should not come bundled with the client. It should be optional, which is also explicitly stated in the specification

I have not reviewed the entire PR because I already have concerns about introducing this in the first place. As I understand it, this is an auth extension, while Authorization itself is described in the specification as optional

Authorization is OPTIONAL for MCP implementations

from https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#protocol-requirements

Even if we decide to support Authorization and its extensions, I think the implementation and API should be designed more carefully. For example, the current impl use of data classes can leak secrets through the auto-generated toString()

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

question Further information is requested

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants