Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
}
Loading