Skip to content
Open
10 changes: 9 additions & 1 deletion conformance-test/run-conformance.sh
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,15 @@ run_client_auth_suite() {
--expected-failures "$SCRIPT_DIR/conformance-baseline.yml" \
"$@" || rc=$?

local extra_scenarios=("auth/client-credentials-jwt" "auth/client-credentials-basic" "auth/cross-app-access-complete-flow")
local extra_scenarios=(
"auth/client-credentials-jwt"
"auth/client-credentials-basic"
"auth/cross-app-access-complete-flow"
# Exercise EnterpriseAuthProvider plugin and discoverAndRequestJwtAuthorizationGrant
# using the same mock IdP/AS infrastructure as cross-app-access-complete-flow.
"auth/cross-app-access-enterprise-auth-provider"
"auth/cross-app-access-discover-and-request"
)
for scenario in "${extra_scenarios[@]}"; do
npx "@modelcontextprotocol/conformance@$CONFORMANCE_VERSION" client \
--command "$CLIENT_DIST" \
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package io.modelcontextprotocol.kotlin.sdk.conformance.auth

import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.sse.SSE
import io.modelcontextprotocol.kotlin.sdk.client.Client
import io.modelcontextprotocol.kotlin.sdk.client.ClientOptions
import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport
import io.modelcontextprotocol.kotlin.sdk.client.auth.DiscoverAndRequestJwtAuthGrantOptions
import io.modelcontextprotocol.kotlin.sdk.client.auth.EnterpriseAuth
import io.modelcontextprotocol.kotlin.sdk.client.auth.EnterpriseAuthProvider
import io.modelcontextprotocol.kotlin.sdk.client.auth.RequestJwtAuthGrantOptions
import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities
import io.modelcontextprotocol.kotlin.sdk.types.Implementation

/**
* SEP-990 cross-app access flow exercised through [EnterpriseAuthProvider] as a Ktor plugin.
*
* Reads `client_id`, `client_secret`, `idp_id_token`, and `idp_token_endpoint` from
* the conformance context. Installs [EnterpriseAuthProvider] on the MCP HTTP client so
* that the plugin transparently handles:
* - MCP authorization server discovery via RFC 8414
* - JAG retrieval via [EnterpriseAuth.requestJwtAuthorizationGrant] (RFC 8693)
* - JWT bearer grant exchange via [EnterpriseAuth.exchangeJwtBearerGrant] (RFC 7523)
* - Access token caching and proactive refresh
*
* Exercises: [EnterpriseAuthProvider], [RequestJwtAuthGrantOptions],
* [EnterpriseAuth.requestJwtAuthorizationGrant], [EnterpriseAuth.exchangeJwtBearerGrant].
*/
internal suspend fun runCrossAppAccessViaEnterpriseAuthProvider(serverUrl: String) {
val ctx = conformanceContext()
val clientId = ctx.requiredString("client_id")
val clientSecret = ctx.requiredString("client_secret")
val idpIdToken = ctx.requiredString("idp_id_token")
val idpTokenEndpoint = ctx.requiredString("idp_token_endpoint")

val authHttpClient = HttpClient(CIO) {
install(SSE)
followRedirects = false
}

val mcpHttpClient = HttpClient(CIO) {
install(SSE)
followRedirects = false
install(EnterpriseAuthProvider) {
this.clientId = clientId
this.clientSecret = clientSecret
this.authHttpClient = authHttpClient
assertionCallback = { assertionCtx ->
// Step 1 (RFC 8693): exchange the enterprise OIDC ID Token for a
// JWT Authorization Grant (ID-JAG) at the enterprise IdP.
EnterpriseAuth.requestJwtAuthorizationGrant(
RequestJwtAuthGrantOptions(
tokenEndpoint = idpTokenEndpoint,
idToken = idpIdToken,
clientId = clientId,
clientSecret = clientSecret,
audience = assertionCtx.authorizationServerUrl,
resource = assertionCtx.resourceUrl,
),
authHttpClient,
)
// Step 2 (RFC 7523): EnterpriseAuthProvider handles the JWT bearer
// grant exchange internally via EnterpriseAuth.exchangeJwtBearerGrant.
}
}
}

mcpHttpClient.use { client ->
val transport = StreamableHttpClientTransport(client, serverUrl)
val mcpClient = Client(
clientInfo = Implementation("conformance-enterprise-auth-provider", "1.0.0"),
options = ClientOptions(capabilities = ClientCapabilities()),
)
mcpClient.connect(transport)
mcpClient.listTools()
mcpClient.close()
}
}

/**
* SEP-990 cross-app access flow that exercises
* [EnterpriseAuth.discoverAndRequestJwtAuthorizationGrant] inside the
* [EnterpriseAuthProvider] assertion callback.
*
* The `idp_token_endpoint` from the conformance context is supplied as
* [DiscoverAndRequestJwtAuthGrantOptions.idpTokenEndpoint], which skips the RFC 8414
* discovery round-trip while still exercising the combined discover-and-request code path.
*
* Exercises: [EnterpriseAuth.discoverAndRequestJwtAuthorizationGrant],
* [DiscoverAndRequestJwtAuthGrantOptions].
*/
internal suspend fun runCrossAppAccessViaDiscoverAndRequest(serverUrl: String) {
val ctx = conformanceContext()
val clientId = ctx.requiredString("client_id")
val clientSecret = ctx.requiredString("client_secret")
val idpIdToken = ctx.requiredString("idp_id_token")
val idpTokenEndpoint = ctx.requiredString("idp_token_endpoint")

val authHttpClient = HttpClient(CIO) {
install(SSE)
followRedirects = false
}

val mcpHttpClient = HttpClient(CIO) {
install(SSE)
followRedirects = false
install(EnterpriseAuthProvider) {
this.clientId = clientId
this.clientSecret = clientSecret
this.authHttpClient = authHttpClient
assertionCallback = { assertionCtx ->
// discoverAndRequestJwtAuthorizationGrant is called with idpTokenEndpoint
// set explicitly so that RFC 8414 discovery is skipped; idpUrl is still
// required by the type but unused when idpTokenEndpoint is non-null.
EnterpriseAuth.discoverAndRequestJwtAuthorizationGrant(
DiscoverAndRequestJwtAuthGrantOptions(
idpUrl = extractOrigin(idpTokenEndpoint),
idpTokenEndpoint = idpTokenEndpoint,
idToken = idpIdToken,
clientId = clientId,
clientSecret = clientSecret,
audience = assertionCtx.authorizationServerUrl,
resource = assertionCtx.resourceUrl,
),
authHttpClient,
)
}
}
}

mcpHttpClient.use { client ->
val transport = StreamableHttpClientTransport(client, serverUrl)
val mcpClient = Client(
clientInfo = Implementation("conformance-discover-and-request", "1.0.0"),
options = ClientOptions(capabilities = ClientCapabilities()),
)
mcpClient.connect(transport)
mcpClient.listTools()
mcpClient.close()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,10 @@ fun registerAuthScenarios() {
scenarioHandlers["auth/client-credentials-jwt"] = ::runClientCredentialsJwt
scenarioHandlers["auth/client-credentials-basic"] = ::runClientCredentialsBasic
scenarioHandlers["auth/cross-app-access-complete-flow"] = ::runCrossAppAccess
// SEP-990 scenarios that exercise the EnterpriseAuthProvider Ktor plugin and the
// discoverAndRequestJwtAuthorizationGrant combined call.
scenarioHandlers["auth/cross-app-access-enterprise-auth-provider"] =
::runCrossAppAccessViaEnterpriseAuthProvider
scenarioHandlers["auth/cross-app-access-discover-and-request"] =
::runCrossAppAccessViaDiscoverAndRequest
}
52 changes: 52 additions & 0 deletions kotlin-sdk-client-enterprise-auth/Module.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Module kotlin-sdk-client-enterprise-auth

Enterprise Managed Authorization (SEP-990) support for Kotlin MCP clients. Implements the
two-step OAuth 2.0 flow — RFC 8693 token exchange for a JWT Authorization Grant followed by
RFC 7523 JWT Bearer grant exchange — as a standalone Ktor [HttpClientPlugin].

**Platform Support:** Multiplatform • Kotlin 2.2+

## Key Classes

- [EnterpriseAuthProvider] — Ktor `HttpClientPlugin`; intercepts outgoing requests, performs
full enterprise auth flow, caches the resulting access token, and injects
`Authorization: Bearer` headers automatically
- [EnterpriseAuthProviderOptions] — configuration: `clientId`, `assertionCallback`, etc.
- [EnterpriseAuthAssertionContext] — context passed to the assertion callback
- [EnterpriseAuth] — low-level utility object with individual `suspend` functions for each
auth step; use when you need fine-grained control
- [AuthServerMetadata] — RFC 8414 discovery response model
- [RequestJwtAuthGrantOptions] / [DiscoverAndRequestJwtAuthGrantOptions] — Step 1 options
- [ExchangeJwtBearerGrantOptions] — Step 2 options
- [JagTokenExchangeResponse] / [JwtBearerAccessTokenResponse] — response models
- [EnterpriseAuthException] — thrown on any auth flow failure

## Example

```kotlin
val httpClient = HttpClient(CIO) {
install(SSE)
install(EnterpriseAuthProvider) {
clientId = "my-mcp-client"
assertionCallback = { ctx ->
EnterpriseAuth.requestJwtAuthorizationGrant(
RequestJwtAuthGrantOptions(
tokenEndpoint = "https://idp.example.com/token",
idToken = myIdTokenSupplier(),
clientId = "my-idp-client",
clientSecret = "idp-client-secret",
audience = ctx.authorizationServerUrl,
resource = ctx.resourceUrl,
),
authHttpClient,
)
}
}
}

val transport = StreamableHttpClientTransport(client = httpClient, url = serverUrl)
```

# Package io.modelcontextprotocol.kotlin.sdk.client.auth

Enterprise Managed Authorization types and utilities (SEP-990).
Loading