diff --git a/conformance-test/run-conformance.sh b/conformance-test/run-conformance.sh index 8f15ac0d1..55fda457f 100755 --- a/conformance-test/run-conformance.sh +++ b/conformance-test/run-conformance.sh @@ -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" \ diff --git a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/auth/enterpriseAuthProviderScenario.kt b/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/auth/enterpriseAuthProviderScenario.kt new file mode 100644 index 000000000..ed41ea8db --- /dev/null +++ b/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/auth/enterpriseAuthProviderScenario.kt @@ -0,0 +1,147 @@ +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") + + // Dedicated HTTP client used only for auth requests (discovery + token exchanges). + // Kept separate from the MCP HTTP client so its lifecycle is explicit. + val authHttpClient = HttpClient(CIO) { + install(SSE) + } + + // Main MCP HTTP client — EnterpriseAuthProvider transparently injects Bearer tokens. + 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. + } + } + } + + authHttpClient.use { + 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) + } + + 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, + ) + } + } + } + + authHttpClient.use { + 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() + } + } +} diff --git a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/auth/registration.kt b/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/auth/registration.kt index 4964f20d2..738d64155 100644 --- a/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/auth/registration.kt +++ b/conformance-test/src/main/kotlin/io/modelcontextprotocol/kotlin/sdk/conformance/auth/registration.kt @@ -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 } diff --git a/kotlin-sdk-client/api/kotlin-sdk-client.api b/kotlin-sdk-client/api/kotlin-sdk-client.api index da0e752a7..0642ea2e3 100644 --- a/kotlin-sdk-client/api/kotlin-sdk-client.api +++ b/kotlin-sdk-client/api/kotlin-sdk-client.api @@ -142,3 +142,238 @@ public final class io/modelcontextprotocol/kotlin/sdk/client/WebSocketMcpKtorCli public static synthetic fun mcpWebSocketTransport$default (Lio/ktor/client/HttpClient;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/client/WebSocketClientTransport; } +public final class io/modelcontextprotocol/kotlin/sdk/client/auth/AuthServerMetadata { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/client/auth/AuthServerMetadata$Companion; + public fun ()V + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/client/auth/AuthServerMetadata; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/client/auth/AuthServerMetadata;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/client/auth/AuthServerMetadata; + public fun equals (Ljava/lang/Object;)Z + public final fun getAuthorizationEndpoint ()Ljava/lang/String; + public final fun getIssuer ()Ljava/lang/String; + public final fun getJwksUri ()Ljava/lang/String; + public final fun getTokenEndpoint ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class io/modelcontextprotocol/kotlin/sdk/client/auth/AuthServerMetadata$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/client/auth/AuthServerMetadata$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/client/auth/AuthServerMetadata; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/modelcontextprotocol/kotlin/sdk/client/auth/AuthServerMetadata;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/client/auth/AuthServerMetadata$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/client/auth/DiscoverAndRequestJwtAuthGrantOptions { + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()Ljava/lang/String; + public final fun component6 ()Ljava/lang/String; + public final fun component7 ()Ljava/lang/String; + public final fun component8 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/client/auth/DiscoverAndRequestJwtAuthGrantOptions; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/client/auth/DiscoverAndRequestJwtAuthGrantOptions;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/client/auth/DiscoverAndRequestJwtAuthGrantOptions; + public fun equals (Ljava/lang/Object;)Z + public final fun getAudience ()Ljava/lang/String; + public final fun getClientId ()Ljava/lang/String; + public final fun getClientSecret ()Ljava/lang/String; + public final fun getIdToken ()Ljava/lang/String; + public final fun getIdpTokenEndpoint ()Ljava/lang/String; + public final fun getIdpUrl ()Ljava/lang/String; + public final fun getResource ()Ljava/lang/String; + public final fun getScope ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/modelcontextprotocol/kotlin/sdk/client/auth/EnterpriseAuth { + public static final field GRANT_TYPE_JWT_BEARER Ljava/lang/String; + public static final field GRANT_TYPE_TOKEN_EXCHANGE Ljava/lang/String; + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/client/auth/EnterpriseAuth; + public static final field TOKEN_TYPE_ID_JAG Ljava/lang/String; + public static final field TOKEN_TYPE_ID_TOKEN Ljava/lang/String; + public final fun discoverAndRequestJwtAuthorizationGrant (Lio/modelcontextprotocol/kotlin/sdk/client/auth/DiscoverAndRequestJwtAuthGrantOptions;Lio/ktor/client/HttpClient;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun discoverAuthServerMetadata (Ljava/lang/String;Lio/ktor/client/HttpClient;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun exchangeJwtBearerGrant (Lio/modelcontextprotocol/kotlin/sdk/client/auth/ExchangeJwtBearerGrantOptions;Lio/ktor/client/HttpClient;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun requestJwtAuthorizationGrant (Lio/modelcontextprotocol/kotlin/sdk/client/auth/RequestJwtAuthGrantOptions;Lio/ktor/client/HttpClient;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class io/modelcontextprotocol/kotlin/sdk/client/auth/EnterpriseAuthAssertionContext { + public fun (Ljava/lang/String;Ljava/lang/String;)V + public final fun getAuthorizationServerUrl ()Ljava/lang/String; + public final fun getResourceUrl ()Ljava/lang/String; +} + +public final class io/modelcontextprotocol/kotlin/sdk/client/auth/EnterpriseAuthException : java/lang/RuntimeException { + public fun (Ljava/lang/String;Ljava/lang/Throwable;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V +} + +public final class io/modelcontextprotocol/kotlin/sdk/client/auth/EnterpriseAuthProvider { + public static final field Plugin Lio/modelcontextprotocol/kotlin/sdk/client/auth/EnterpriseAuthProvider$Plugin; + public fun (Lio/modelcontextprotocol/kotlin/sdk/client/auth/EnterpriseAuthProviderOptions;Lio/ktor/client/HttpClient;)V + public synthetic fun (Lio/modelcontextprotocol/kotlin/sdk/client/auth/EnterpriseAuthProviderOptions;Lio/ktor/client/HttpClient;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun invalidateCache ()V +} + +public final class io/modelcontextprotocol/kotlin/sdk/client/auth/EnterpriseAuthProvider$Config { + public fun ()V + public final fun getAssertionCallback ()Lkotlin/jvm/functions/Function2; + public final fun getAuthHttpClient ()Lio/ktor/client/HttpClient; + public final fun getClientId ()Ljava/lang/String; + public final fun getClientSecret ()Ljava/lang/String; + public final fun getExpiryBuffer-UwyO8pc ()J + public final fun getScope ()Ljava/lang/String; + public final fun setAssertionCallback (Lkotlin/jvm/functions/Function2;)V + public final fun setAuthHttpClient (Lio/ktor/client/HttpClient;)V + public final fun setClientId (Ljava/lang/String;)V + public final fun setClientSecret (Ljava/lang/String;)V + public final fun setExpiryBuffer-LRDsOJo (J)V + public final fun setScope (Ljava/lang/String;)V +} + +public final class io/modelcontextprotocol/kotlin/sdk/client/auth/EnterpriseAuthProvider$Plugin : io/ktor/client/plugins/HttpClientPlugin { + public fun getKey ()Lio/ktor/util/AttributeKey; + public fun install (Lio/modelcontextprotocol/kotlin/sdk/client/auth/EnterpriseAuthProvider;Lio/ktor/client/HttpClient;)V + public synthetic fun install (Ljava/lang/Object;Lio/ktor/client/HttpClient;)V + public fun prepare (Lkotlin/jvm/functions/Function1;)Lio/modelcontextprotocol/kotlin/sdk/client/auth/EnterpriseAuthProvider; + public synthetic fun prepare (Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; +} + +public final class io/modelcontextprotocol/kotlin/sdk/client/auth/EnterpriseAuthProviderOptions { + public synthetic fun (Ljava/lang/String;Lkotlin/jvm/functions/Function2;Ljava/lang/String;Ljava/lang/String;JILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/String;Lkotlin/jvm/functions/Function2;Ljava/lang/String;Ljava/lang/String;JLkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getAssertionCallback ()Lkotlin/jvm/functions/Function2; + public final fun getClientId ()Ljava/lang/String; + public final fun getClientSecret ()Ljava/lang/String; + public final fun getExpiryBuffer-UwyO8pc ()J + public final fun getScope ()Ljava/lang/String; +} + +public final class io/modelcontextprotocol/kotlin/sdk/client/auth/ExchangeJwtBearerGrantOptions { + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/client/auth/ExchangeJwtBearerGrantOptions; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/client/auth/ExchangeJwtBearerGrantOptions;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/client/auth/ExchangeJwtBearerGrantOptions; + public fun equals (Ljava/lang/Object;)Z + public final fun getAssertion ()Ljava/lang/String; + public final fun getClientId ()Ljava/lang/String; + public final fun getClientSecret ()Ljava/lang/String; + public final fun getScope ()Ljava/lang/String; + public final fun getTokenEndpoint ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/modelcontextprotocol/kotlin/sdk/client/auth/JagTokenExchangeResponse { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/client/auth/JagTokenExchangeResponse$Companion; + public fun ()V + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()Ljava/lang/Integer; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;)Lio/modelcontextprotocol/kotlin/sdk/client/auth/JagTokenExchangeResponse; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/client/auth/JagTokenExchangeResponse;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/client/auth/JagTokenExchangeResponse; + public fun equals (Ljava/lang/Object;)Z + public final fun getAccessToken ()Ljava/lang/String; + public final fun getExpiresIn ()Ljava/lang/Integer; + public final fun getIssuedTokenType ()Ljava/lang/String; + public final fun getScope ()Ljava/lang/String; + public final fun getTokenType ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final synthetic class io/modelcontextprotocol/kotlin/sdk/client/auth/JagTokenExchangeResponse$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/client/auth/JagTokenExchangeResponse$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/client/auth/JagTokenExchangeResponse; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/modelcontextprotocol/kotlin/sdk/client/auth/JagTokenExchangeResponse;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/client/auth/JagTokenExchangeResponse$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/client/auth/JwtBearerAccessTokenResponse { + public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/client/auth/JwtBearerAccessTokenResponse$Companion; + public fun ()V + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getAccessToken ()Ljava/lang/String; + public final fun getExpiresAt ()Lkotlin/time/ComparableTimeMark; + public final fun getExpiresIn ()Ljava/lang/Integer; + public final fun getRefreshToken ()Ljava/lang/String; + public final fun getScope ()Ljava/lang/String; + public final fun getTokenType ()Ljava/lang/String; + public final fun isExpired ()Z +} + +public final synthetic class io/modelcontextprotocol/kotlin/sdk/client/auth/JwtBearerAccessTokenResponse$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lio/modelcontextprotocol/kotlin/sdk/client/auth/JwtBearerAccessTokenResponse$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lio/modelcontextprotocol/kotlin/sdk/client/auth/JwtBearerAccessTokenResponse; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lio/modelcontextprotocol/kotlin/sdk/client/auth/JwtBearerAccessTokenResponse;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/client/auth/JwtBearerAccessTokenResponse$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class io/modelcontextprotocol/kotlin/sdk/client/auth/RequestJwtAuthGrantOptions { + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()Ljava/lang/String; + public final fun component6 ()Ljava/lang/String; + public final fun component7 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/client/auth/RequestJwtAuthGrantOptions; + public static synthetic fun copy$default (Lio/modelcontextprotocol/kotlin/sdk/client/auth/RequestJwtAuthGrantOptions;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/client/auth/RequestJwtAuthGrantOptions; + public fun equals (Ljava/lang/Object;)Z + public final fun getAudience ()Ljava/lang/String; + public final fun getClientId ()Ljava/lang/String; + public final fun getClientSecret ()Ljava/lang/String; + public final fun getIdToken ()Ljava/lang/String; + public final fun getResource ()Ljava/lang/String; + public final fun getScope ()Ljava/lang/String; + public final fun getTokenEndpoint ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + diff --git a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/auth/AuthServerMetadata.kt b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/auth/AuthServerMetadata.kt new file mode 100644 index 000000000..e14da7d77 --- /dev/null +++ b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/auth/AuthServerMetadata.kt @@ -0,0 +1,26 @@ +package io.modelcontextprotocol.kotlin.sdk.client.auth + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * OAuth 2.0 Authorization Server Metadata as defined by RFC 8414. + * + * Returned by the `/.well-known/oauth-authorization-server` or + * `/.well-known/openid-configuration` discovery endpoints and used during + * Enterprise Managed Authorization (SEP-990) to locate the token endpoint of the + * enterprise Identity Provider and the MCP authorization server. + * + * @see RFC 8414 + */ +@Serializable +public data class AuthServerMetadata( + /** The authorization server's issuer identifier URI. */ + val issuer: String? = null, + /** The URL of the token endpoint used for token exchange and JWT Bearer grant requests. */ + @SerialName("token_endpoint") val tokenEndpoint: String? = null, + /** The URL of the authorization endpoint (for interactive flows). */ + @SerialName("authorization_endpoint") val authorizationEndpoint: String? = null, + /** The URL of the JSON Web Key Set (JWKS) for public key retrieval. */ + @SerialName("jwks_uri") val jwksUri: String? = null, +) diff --git a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/auth/DiscoverAndRequestJwtAuthGrantOptions.kt b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/auth/DiscoverAndRequestJwtAuthGrantOptions.kt new file mode 100644 index 000000000..1d513268b --- /dev/null +++ b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/auth/DiscoverAndRequestJwtAuthGrantOptions.kt @@ -0,0 +1,32 @@ +package io.modelcontextprotocol.kotlin.sdk.client.auth + +/** + * Options for [EnterpriseAuth.discoverAndRequestJwtAuthorizationGrant] — performs Step 1 + * of the Enterprise Managed Authorization (SEP-990) flow, first discovering the IdP's + * token endpoint via RFC 8414 metadata discovery, then requesting the JAG. + * + * If [idpTokenEndpoint] is provided, the discovery step is skipped and the provided + * endpoint is used directly. + * + * @param idpUrl The base URL of the enterprise IdP. Used for RFC 8414 discovery when + * [idpTokenEndpoint] is not set (tries `/.well-known/oauth-authorization-server` and + * then `/.well-known/openid-configuration`). + * @param idToken The OIDC ID token issued by the enterprise IdP. + * @param clientId The OAuth 2.0 client ID registered at the enterprise IdP. + * @param idpTokenEndpoint Optional override for the IdP's token endpoint. When provided, + * RFC 8414 discovery is skipped. + * @param clientSecret The OAuth 2.0 client secret (optional; `null` for public clients). + * @param audience Optional `audience` parameter for the token exchange request. + * @param resource Optional `resource` parameter for the token exchange request. + * @param scope Optional `scope` parameter for the token exchange request. + */ +public data class DiscoverAndRequestJwtAuthGrantOptions( + public val idpUrl: String, + public val idToken: String, + public val clientId: String, + public val idpTokenEndpoint: String? = null, + public val clientSecret: String? = null, + public val audience: String? = null, + public val resource: String? = null, + public val scope: String? = null, +) diff --git a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/auth/EnterpriseAuth.kt b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/auth/EnterpriseAuth.kt new file mode 100644 index 000000000..25b81c544 --- /dev/null +++ b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/auth/EnterpriseAuth.kt @@ -0,0 +1,338 @@ +package io.modelcontextprotocol.kotlin.sdk.client.auth + +import io.github.oshai.kotlinlogging.KotlinLogging +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.client.request.headers +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.http.encodeURLParameter +import io.ktor.util.encodeBase64 +import io.modelcontextprotocol.kotlin.sdk.types.McpJson +import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeSource + +private val logger = KotlinLogging.logger {} + +/** + * Layer 2 utility object for the Enterprise Managed Authorization (SEP-990) flow. + * + * Provides standalone `suspend` functions for each discrete step of the two-step enterprise + * auth protocol: + * + * 1. **Step 1 — JAG request:** Exchange an enterprise OIDC ID Token for a JWT + * Authorization Grant (ID-JAG) at the enterprise IdP via RFC 8693 token exchange. + * Use [requestJwtAuthorizationGrant] or [discoverAndRequestJwtAuthorizationGrant]. + * + * 2. **Step 2 — access token exchange:** Exchange the ID-JAG for an OAuth 2.0 access + * token at the MCP authorization server via RFC 7523 JWT Bearer grant. + * Use [exchangeJwtBearerGrant]. + * + * For a higher-level, stateful integration that handles both steps and caches the + * resulting access token, use [EnterpriseAuthProvider] instead. + * + * All functions require a Ktor [HttpClient] to be provided by the caller. They do not + * manage the lifecycle of that client. + * + * @see EnterpriseAuthProvider + * @see RFC 8414 — Authorization Server Metadata + * @see RFC 8693 — Token Exchange + * @see RFC 7523 — JWT Bearer Grant + */ +public object EnterpriseAuth { + + /** + * Token type URI for OIDC ID tokens, used as the `subject_token_type` in the + * RFC 8693 token exchange request. + */ + public const val TOKEN_TYPE_ID_TOKEN: String = "urn:ietf:params:oauth:token-type:id_token" + + /** + * Token type URI for JWT Authorization Grants (ID-JAG), used as the + * `requested_token_type` in the token exchange request and validated as the + * `issued_token_type` in the response. + */ + public const val TOKEN_TYPE_ID_JAG: String = "urn:ietf:params:oauth:token-type:id-jag" + + /** + * Grant type URI for RFC 8693 token exchange requests. + */ + public const val GRANT_TYPE_TOKEN_EXCHANGE: String = "urn:ietf:params:oauth:grant-type:token-exchange" + + /** + * Grant type URI for RFC 7523 JWT Bearer grant requests. + */ + public const val GRANT_TYPE_JWT_BEARER: String = "urn:ietf:params:oauth:grant-type:jwt-bearer" + + private const val WELL_KNOWN_OAUTH: String = "/.well-known/oauth-authorization-server" + private const val WELL_KNOWN_OPENID: String = "/.well-known/openid-configuration" + + /** URL-encodes [key] and [value] and joins them with `=`, using `+` for spaces. */ + private fun encodeParam(key: String, value: String): String = + key.encodeURLParameter().replace("%20", "+") + "=" + value.encodeURLParameter().replace("%20", "+") + + // ----------------------------------------------------------------------- + // Authorization server discovery (RFC 8414) + // ----------------------------------------------------------------------- + + /** + * Discovers the OAuth 2.0 authorization server metadata for the given base URL using + * RFC 8414. + * + * First attempts to retrieve metadata from `{url}/.well-known/oauth-authorization-server`. + * If that fails (non-200 response or network error), falls back to + * `{url}/.well-known/openid-configuration`. + * + * @param url The base URL of the authorization or resource server (trailing slash is stripped). + * @param httpClient The HTTP client to use for the discovery request. + * @return The parsed [AuthServerMetadata]. + * @throws EnterpriseAuthException if both endpoints fail. + */ + public suspend fun discoverAuthServerMetadata(url: String, httpClient: HttpClient): AuthServerMetadata { + val baseUrl = url.trimEnd('/') + val oauthUrl = "$baseUrl$WELL_KNOWN_OAUTH" + val openIdUrl = "$baseUrl$WELL_KNOWN_OPENID" + logger.debug { "Discovering authorization server metadata for $baseUrl" } + return try { + fetchAuthServerMetadata(oauthUrl, httpClient) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.debug { "OAuth discovery failed ($oauthUrl), falling back to OpenID configuration: ${e.message}" } + fetchAuthServerMetadata(openIdUrl, httpClient) + } + } + + private suspend fun fetchAuthServerMetadata(url: String, httpClient: HttpClient): AuthServerMetadata { + val response = httpClient.get(url) { + headers { append(HttpHeaders.Accept, "application/json") } + } + if (response.status != HttpStatusCode.OK) { + throw EnterpriseAuthException( + "Failed to discover authorization server metadata from $url: HTTP ${response.status.value}", + ) + } + return try { + val body = response.bodyAsText() + val metadata = McpJson.decodeFromString(body) + logger.debug { + "Discovered authorization server metadata from $url: " + + "issuer=${metadata.issuer}, tokenEndpoint=${metadata.tokenEndpoint}" + } + metadata + } catch (e: EnterpriseAuthException) { + throw e + } catch (e: Exception) { + throw EnterpriseAuthException("Failed to parse authorization server metadata from $url", e) + } + } + + // ----------------------------------------------------------------------- + // Step 1 — JAG request (RFC 8693 token exchange) + // ----------------------------------------------------------------------- + + /** + * Requests a JWT Authorization Grant (ID-JAG) by performing an RFC 8693 token exchange + * at the specified token endpoint. + * + * Exchanges the enterprise OIDC ID Token for an ID-JAG that can subsequently be + * presented to the MCP authorization server via [exchangeJwtBearerGrant]. + * + * Validates that the response `issued_token_type` equals [TOKEN_TYPE_ID_JAG]. + * + * @param options Request parameters including the IdP token endpoint, ID Token, and + * client credentials. + * @param httpClient The HTTP client to use. + * @return The JAG string (the `access_token` value from the exchange response). + * @throws EnterpriseAuthException on HTTP error, unexpected token types, or parse failure. + */ + public suspend fun requestJwtAuthorizationGrant( + options: RequestJwtAuthGrantOptions, + httpClient: HttpClient, + ): String { + val params = buildList { + add("grant_type" to GRANT_TYPE_TOKEN_EXCHANGE) + add("subject_token" to options.idToken) + add("subject_token_type" to TOKEN_TYPE_ID_TOKEN) + add("requested_token_type" to TOKEN_TYPE_ID_JAG) + add("client_id" to options.clientId) + options.clientSecret?.let { add("client_secret" to it) } + options.audience?.let { add("audience" to it) } + options.resource?.let { add("resource" to it) } + options.scope?.let { add("scope" to it) } + } + val body = params.joinToString("&") { (k, v) -> encodeParam(k, v) } + + logger.debug { "Requesting JAG token exchange at ${options.tokenEndpoint}" } + + val response = httpClient.post(options.tokenEndpoint) { + contentType(ContentType.Application.FormUrlEncoded) + headers { append(HttpHeaders.Accept, "application/json") } + setBody(body) + } + + if (response.status != HttpStatusCode.OK) { + throw EnterpriseAuthException( + "JAG token exchange failed: HTTP ${response.status.value} - ${response.bodyAsText()}", + ) + } + + return try { + val tokenResponse = McpJson.decodeFromString(response.bodyAsText()) + + validateJAGTokenExchangeResponse(tokenResponse) + + logger.debug { "JAG token exchange successful" } + tokenResponse.accessToken!! + } catch (e: EnterpriseAuthException) { + throw e + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + throw EnterpriseAuthException("Failed to parse JAG token exchange response", e) + } + } + + private fun validateJAGTokenExchangeResponse(tokenResponse: JagTokenExchangeResponse) { + if (!TOKEN_TYPE_ID_JAG.equals(tokenResponse.issuedTokenType, ignoreCase = true)) { + throw EnterpriseAuthException( + "Unexpected issued_token_type in JAG response: " + + "${tokenResponse.issuedTokenType} (expected $TOKEN_TYPE_ID_JAG)", + ) + } + // token_type is informational per RFC 8693 §2.2.1; not strictly validated + // because some conformant IdPs omit or vary the field. + if (tokenResponse.accessToken.isNullOrBlank()) { + throw EnterpriseAuthException("JAG token exchange response is missing access_token") + } + } + + /** + * Discovers the enterprise IdP's token endpoint via RFC 8414, then requests a JAG via + * RFC 8693 token exchange. + * + * If [DiscoverAndRequestJwtAuthGrantOptions.idpTokenEndpoint] is set, the discovery + * step is skipped and the provided endpoint is used directly. + * + * @param options Request parameters including the IdP base URL (for discovery), ID Token, + * and client credentials. + * @param httpClient The HTTP client to use. + * @return The JAG string. + * @throws EnterpriseAuthException on discovery or exchange failure. + */ + public suspend fun discoverAndRequestJwtAuthorizationGrant( + options: DiscoverAndRequestJwtAuthGrantOptions, + httpClient: HttpClient, + ): String { + val tokenEndpoint = if (options.idpTokenEndpoint != null) { + // Caller has already performed RFC 8414 discovery (or knows the endpoint ahead of time); + // skip the discovery round-trip. + options.idpTokenEndpoint + } else { + val metadata = discoverAuthServerMetadata(options.idpUrl, httpClient) + metadata.tokenEndpoint + ?: throw EnterpriseAuthException( + "No token_endpoint in IdP metadata at ${options.idpUrl}. " + + "Ensure the IdP supports RFC 8414.", + ) + } + + return requestJwtAuthorizationGrant( + RequestJwtAuthGrantOptions( + tokenEndpoint = tokenEndpoint, + idToken = options.idToken, + clientId = options.clientId, + clientSecret = options.clientSecret, + audience = options.audience, + resource = options.resource, + scope = options.scope, + ), + httpClient, + ) + } + + // ----------------------------------------------------------------------- + // Step 2 — JWT Bearer grant exchange (RFC 7523) + // ----------------------------------------------------------------------- + + /** + * Exchanges a JWT Authorization Grant (ID-JAG) for an OAuth 2.0 access token at the + * MCP authorization server's token endpoint using RFC 7523. + * + * The returned [JwtBearerAccessTokenResponse] includes the access token and, if the + * server provided an `expires_in` value, an absolute [JwtBearerAccessTokenResponse.expiresAt] + * timestamp computed from [kotlin.time.TimeSource.Monotonic]. + * + * @param options Request parameters including the MCP auth server token endpoint, JAG + * assertion, and client credentials. + * @param httpClient The HTTP client to use. + * @return The [JwtBearerAccessTokenResponse]. + * @throws EnterpriseAuthException on HTTP error, missing `access_token`, or parse failure. + */ + public suspend fun exchangeJwtBearerGrant( + options: ExchangeJwtBearerGrantOptions, + httpClient: HttpClient, + ): JwtBearerAccessTokenResponse { + val params = buildList { + add("grant_type" to GRANT_TYPE_JWT_BEARER) + add("assertion" to options.assertion) + options.scope?.let { add("scope" to it) } + } + val body = params.joinToString("&") { (k, v) -> encodeParam(k, v) } + + // Client credentials are sent using client_secret_basic (RFC 6749 §2.3.1): + // clientId and clientSecret are Base64-encoded and sent in the Authorization: Basic header. + val credentials = "${options.clientId}:${options.clientSecret ?: ""}" + val basicAuth = credentials.encodeToByteArray().encodeBase64() + + logger.debug { "Exchanging JWT bearer grant at ${options.tokenEndpoint}" } + + val response = httpClient.post(options.tokenEndpoint) { + contentType(ContentType.Application.FormUrlEncoded) + headers { + append(HttpHeaders.Accept, "application/json") + append(HttpHeaders.Authorization, "Basic $basicAuth") + } + setBody(body) + } + + if (response.status != HttpStatusCode.OK) { + throw EnterpriseAuthException( + "JWT bearer grant exchange failed: HTTP ${response.status.value} - ${response.bodyAsText()}", + ) + } + + return try { + val tokenResponse = McpJson.decodeFromString(response.bodyAsText()) + + if (tokenResponse.accessToken.isNullOrBlank()) { + throw EnterpriseAuthException("JWT bearer grant exchange response is missing access_token") + } + + // RFC 7523 is a stateless grant — no refresh token is expected or used. + // If the AS returns one, we intentionally ignore it: using it would bypass + // re-validation of the user's identity with the IdP and undermine + // session / revocation policies. + + // Compute absolute expiry from relative expires_in using a monotonic clock + tokenResponse.expiresIn?.let { expiresIn -> + tokenResponse.expiresAt = TimeSource.Monotonic.markNow() + expiresIn.seconds + } + + logger.debug { "JWT bearer grant exchange successful; expires_in=${tokenResponse.expiresIn}" } + tokenResponse + } catch (e: EnterpriseAuthException) { + throw e + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + throw EnterpriseAuthException("Failed to parse JWT bearer grant exchange response", e) + } + } +} diff --git a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/auth/EnterpriseAuthAssertionContext.kt b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/auth/EnterpriseAuthAssertionContext.kt new file mode 100644 index 000000000..cea50de88 --- /dev/null +++ b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/auth/EnterpriseAuthAssertionContext.kt @@ -0,0 +1,17 @@ +package io.modelcontextprotocol.kotlin.sdk.client.auth + +/** + * Context passed to the [EnterpriseAuthProviderOptions.assertionCallback]. + * + * Contains the resource URL of the MCP server and the URL of the authorization server + * discovered for that resource. The callback uses this context to obtain a suitable + * assertion (e.g., an OIDC ID token) from the enterprise IdP. + * + * @param resourceUrl The base URL of the MCP resource being accessed. + * @param authorizationServerUrl The URL of the MCP authorization server discovered for + * the resource (the `issuer` from RFC 8414 metadata, or the resource base URL). + */ +public class EnterpriseAuthAssertionContext( + public val resourceUrl: String, + public val authorizationServerUrl: String, +) diff --git a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/auth/EnterpriseAuthException.kt b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/auth/EnterpriseAuthException.kt new file mode 100644 index 000000000..d0d904b5c --- /dev/null +++ b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/auth/EnterpriseAuthException.kt @@ -0,0 +1,14 @@ +package io.modelcontextprotocol.kotlin.sdk.client.auth + +/** + * Exception thrown when an error occurs during the Enterprise Managed Authorization (SEP-990) flow. + * + * This includes failures during: + * - OAuth 2.0 authorization server metadata discovery (RFC 8414) + * - RFC 8693 token exchange (ID Token → ID-JAG) + * - RFC 7523 JWT Bearer grant exchange (ID-JAG → Access Token) + */ +public class EnterpriseAuthException( + message: String, + cause: Throwable? = null, +) : RuntimeException(message, cause) diff --git a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/auth/EnterpriseAuthProvider.kt b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/auth/EnterpriseAuthProvider.kt new file mode 100644 index 000000000..f05ac70f4 --- /dev/null +++ b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/auth/EnterpriseAuthProvider.kt @@ -0,0 +1,255 @@ +package io.modelcontextprotocol.kotlin.sdk.client.auth + +import io.github.oshai.kotlinlogging.KotlinLogging +import io.ktor.client.HttpClient +import io.ktor.client.plugins.HttpClientPlugin +import io.ktor.client.plugins.HttpSend +import io.ktor.client.plugins.plugin +import io.ktor.http.HttpHeaders +import io.ktor.http.Url +import io.ktor.util.AttributeKey +import kotlinx.atomicfu.atomic +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +private val logger = KotlinLogging.logger {} + +/** + * Layer 3 implementation of Enterprise Managed Authorization (SEP-990). + * + * Integrates with any Ktor [HttpClient] as an [HttpClientPlugin]. When installed, it + * intercepts every outgoing HTTP request via Ktor's [HttpSend] mechanism and: + * + * 1. Checks an in-memory access token cache. + * 2. If the cache is empty or the token expires within a 30-second buffer, performs the + * full enterprise auth flow: + * a. Discovers the MCP authorization server metadata via RFC 8414. + * b. Invokes the [EnterpriseAuthProviderOptions.assertionCallback] to obtain a JWT + * Authorization Grant (ID-JAG) from the enterprise IdP. + * c. Exchanges the JAG for an OAuth 2.0 access token via RFC 7523. + * d. Caches the resulting token. + * 3. Adds an `Authorization: Bearer {token}` header to the outgoing request. + * + * ## Usage as Ktor plugin (recommended) + * + * ```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) + * ``` + * + * ## Cache invalidation + * + * ```kotlin + * val provider = httpClient.plugin(EnterpriseAuthProvider) + * provider.invalidateCache() // force full re-fetch on next request + * ``` + * + * ## Direct construction + * + * ```kotlin + * val provider = EnterpriseAuthProvider( + * options = EnterpriseAuthProviderOptions( + * clientId = "my-client", + * assertionCallback = { ctx -> /* obtain JAG */ }, + * ), + * authHttpClient = HttpClient(CIO), + * ) + * ``` + * + * @see EnterpriseAuth + * @see EnterpriseAuthProviderOptions + */ +public class EnterpriseAuthProvider( + private val options: EnterpriseAuthProviderOptions, + private val authHttpClient: HttpClient = HttpClient(), +) { + private val mutex = Mutex() + + private val cachedTokenRef = atomic(null) + + /** + * Invalidates the cached access token, forcing the next request to perform a full + * enterprise auth flow. + * + * Useful after receiving a `401 Unauthorized` response from the MCP server. + */ + public fun invalidateCache() { + logger.debug { "Invalidating cached enterprise auth token" } + cachedTokenRef.value = null + } + + internal suspend fun getAccessToken(requestUrl: Url): String { + // Fast path: read without lock — avoids lock contention on the hot path + val cached = cachedTokenRef.value + if (cached != null && !isExpiredOrNearlyExpired(cached)) { + logger.debug { "Using cached enterprise auth token" } + return requireNotNull(cached.accessToken) { "Cached token has null accessToken" } + } + // Slow path: acquire lock, re-check, then fetch if still stale + return mutex.withLock { + val recheckCached = cachedTokenRef.value + if (recheckCached != null && !isExpiredOrNearlyExpired(recheckCached)) { + logger.debug { "Using cached enterprise auth token (after lock)" } + return@withLock requireNotNull(recheckCached.accessToken) { + "Cached token has null accessToken" + } + } + logger.debug { "Cached enterprise auth token absent or near-expiry; fetching new token" } + val newToken = fetchNewToken(requestUrl) + cachedTokenRef.value = newToken + logger.debug { + "Cached new enterprise auth token; expires_in=${newToken.expiresIn?.let { "${it}s" } ?: "unknown"}" + } + requireNotNull(newToken.accessToken) { "Fetched token has null accessToken" } + } + } + + private fun isExpiredOrNearlyExpired(token: JwtBearerAccessTokenResponse): Boolean { + val expiresAt = token.expiresAt ?: return false + return (expiresAt - options.expiryBuffer).hasPassedNow() + } + + private suspend fun fetchNewToken(requestUrl: Url): JwtBearerAccessTokenResponse { + val resourceBaseUrl = deriveBaseUrl(requestUrl) + logger.debug { "Discovering MCP authorization server for resource $resourceBaseUrl" } + + val metadata = EnterpriseAuth.discoverAuthServerMetadata(resourceBaseUrl, authHttpClient) + val tokenEndpoint = metadata.tokenEndpoint + ?: throw EnterpriseAuthException( + "No token_endpoint in authorization server metadata for $resourceBaseUrl. " + + "Ensure the MCP server supports RFC 8414.", + ) + + // Prefer the issuer URI from metadata; fall back to the resource base URL + val authServerUrl = if (!metadata.issuer.isNullOrBlank()) metadata.issuer else resourceBaseUrl + + val assertionContext = EnterpriseAuthAssertionContext( + resourceUrl = resourceBaseUrl, + authorizationServerUrl = authServerUrl, + ) + logger.debug { + "Invoking assertion callback for resourceUrl=$resourceBaseUrl, authServerUrl=$authServerUrl" + } + + val assertion = options.assertionCallback(assertionContext) + require(assertion.isNotBlank()) { "assertionCallback returned a blank string" } + + return EnterpriseAuth.exchangeJwtBearerGrant( + ExchangeJwtBearerGrantOptions( + tokenEndpoint = tokenEndpoint, + assertion = assertion, + clientId = options.clientId, + clientSecret = options.clientSecret, + scope = options.scope, + ), + authHttpClient, + ) + } + + /** + * Companion object that implements [HttpClientPlugin], allowing [EnterpriseAuthProvider] + * to be installed directly on an [HttpClient] via `install(EnterpriseAuthProvider) { ... }`. + */ + public companion object Plugin : HttpClientPlugin { + + override val key: AttributeKey = + AttributeKey("EnterpriseAuthProvider") + + override fun prepare(block: Config.() -> Unit): EnterpriseAuthProvider { + val config = Config().apply(block) + val options = EnterpriseAuthProviderOptions( + clientId = requireNotNull(config.clientId) { "clientId must not be null" }, + assertionCallback = requireNotNull(config.assertionCallback) { + "assertionCallback must not be null" + }, + clientSecret = config.clientSecret, + scope = config.scope, + expiryBuffer = config.expiryBuffer, + ) + return EnterpriseAuthProvider( + options = options, + authHttpClient = config.authHttpClient ?: HttpClient(), + ) + } + + override fun install(plugin: EnterpriseAuthProvider, scope: HttpClient) { + scope.plugin(HttpSend).intercept { request -> + val token = plugin.getAccessToken(request.url.build()) + request.headers.append(HttpHeaders.Authorization, "Bearer $token") + execute(request) + } + } + } + + /** + * DSL configuration class for `install(EnterpriseAuthProvider) { ... }`. + */ + public class Config { + /** The OAuth 2.0 client ID registered at the MCP authorization server. Required. */ + public var clientId: String? = null + + /** The OAuth 2.0 client secret. Optional for public clients. */ + public var clientSecret: String? = null + + /** The `scope` parameter for the JWT bearer grant exchange. Optional. */ + public var scope: String? = null + + /** + * How far before the token's actual expiry to proactively refresh it. + * Defaults to 30 seconds to account for clock skew and network latency. + */ + public var expiryBuffer: Duration = 30.seconds + + /** + * Callback that obtains a JWT Authorization Grant (ID-JAG) for the given context. + * + * Receives an [EnterpriseAuthAssertionContext] describing the MCP resource and its + * authorization server, and must return the assertion string (e.g., the result of + * [EnterpriseAuth.requestJwtAuthorizationGrant]). Required. + */ + public var assertionCallback: (suspend (EnterpriseAuthAssertionContext) -> String)? = null + + /** + * HTTP client used for auth discovery and token exchange requests (separate from + * the main MCP client). If not provided, a new [HttpClient] is created using + * engine auto-detection. + */ + public var authHttpClient: HttpClient? = null + } +} + +/** + * Derives the scheme + host + port (if non-default) from the given [Url], stripping + * any path, query, or fragment. This is the URL against which RFC 8414 discovery is performed. + */ +private fun deriveBaseUrl(url: Url): String = buildString { + append(url.protocol.name) + append("://") + append(url.host) + val port = url.port + if (port != url.protocol.defaultPort) { + append(':') + append(port) + } +} diff --git a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/auth/EnterpriseAuthProviderOptions.kt b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/auth/EnterpriseAuthProviderOptions.kt new file mode 100644 index 000000000..d6a52e716 --- /dev/null +++ b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/auth/EnterpriseAuthProviderOptions.kt @@ -0,0 +1,27 @@ +package io.modelcontextprotocol.kotlin.sdk.client.auth + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * Configuration options for [EnterpriseAuthProvider]. + * + * At minimum, [clientId] and [assertionCallback] are required. + * + * @param clientId The OAuth 2.0 client ID registered at the MCP authorization server. Required. + * @param assertionCallback Callback that obtains a JWT Authorization Grant (ID-JAG) assertion + * for the given [EnterpriseAuthAssertionContext]. Required. The callback receives context + * describing the MCP resource and its authorization server, and must return the assertion + * string (e.g., the result of [EnterpriseAuth.requestJwtAuthorizationGrant]). + * @param clientSecret The OAuth 2.0 client secret. Optional for public clients. + * @param scope The `scope` parameter to request when exchanging the JWT bearer grant. Optional. + * @param expiryBuffer How far before the token's actual expiry to proactively refresh it. + * Defaults to 30 seconds to account for clock skew and network latency. + */ +public class EnterpriseAuthProviderOptions( + public val clientId: String, + public val assertionCallback: suspend (EnterpriseAuthAssertionContext) -> String, + public val clientSecret: String? = null, + public val scope: String? = null, + public val expiryBuffer: Duration = 30.seconds, +) diff --git a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/auth/ExchangeJwtBearerGrantOptions.kt b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/auth/ExchangeJwtBearerGrantOptions.kt new file mode 100644 index 000000000..5b2234c1e --- /dev/null +++ b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/auth/ExchangeJwtBearerGrantOptions.kt @@ -0,0 +1,29 @@ +package io.modelcontextprotocol.kotlin.sdk.client.auth + +/** + * Options for [EnterpriseAuth.exchangeJwtBearerGrant] — performs Step 2 of the + * Enterprise Managed Authorization (SEP-990) flow. + * + * Posts an RFC 7523 JWT Bearer grant exchange to the MCP authorization server's token + * endpoint, exchanging the JAG (JWT Authorization Grant / ID-JAG) for a standard OAuth + * 2.0 access token that can be used to call the MCP server. + * + * Client credentials are sent using `client_secret_basic` (RFC 6749 §2.3.1): the + * [clientId] and [clientSecret] are Base64-encoded and sent in an `Authorization: Basic` + * header. This matches SEP-990 conformance requirements. + * + * @param tokenEndpoint The full URL of the MCP authorization server's token endpoint. + * @param assertion The JWT Authorization Grant (ID-JAG) obtained from Step 1. + * @param clientId The OAuth 2.0 client ID registered at the MCP authorization server. + * @param clientSecret The OAuth 2.0 client secret (optional; `null` for public clients). + * @param scope Optional `scope` parameter for the token request. + * + * @see RFC 7523 + */ +public data class ExchangeJwtBearerGrantOptions( + public val tokenEndpoint: String, + public val assertion: String, + public val clientId: String, + public val clientSecret: String? = null, + public val scope: String? = null, +) diff --git a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/auth/JagTokenExchangeResponse.kt b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/auth/JagTokenExchangeResponse.kt new file mode 100644 index 000000000..cb9cff7f2 --- /dev/null +++ b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/auth/JagTokenExchangeResponse.kt @@ -0,0 +1,28 @@ +package io.modelcontextprotocol.kotlin.sdk.client.auth + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * RFC 8693 Token Exchange response for the JAG (JWT Authorization Grant) flow. + * + * Returned by the enterprise IdP when exchanging an ID Token for a JWT Authorization + * Grant (ID-JAG) during Enterprise Managed Authorization (SEP-990). + * + * The key fields are: + * - [accessToken] — the issued JAG (despite the name, this is an ID-JAG, not an OAuth access token) + * - [issuedTokenType] — must be `urn:ietf:params:oauth:token-type:id-jag` + * - [tokenType] — informational; per RFC 8693 §2.2.1 it SHOULD be `N_A` when the issued + * token is not an access token, but this is not strictly enforced as some conformant + * IdPs may omit or vary the field + * + * @see RFC 8693 + */ +@Serializable +public data class JagTokenExchangeResponse( + @SerialName("access_token") val accessToken: String? = null, + @SerialName("issued_token_type") val issuedTokenType: String? = null, + @SerialName("token_type") val tokenType: String? = null, + val scope: String? = null, + @SerialName("expires_in") val expiresIn: Int? = null, +) diff --git a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/auth/JwtBearerAccessTokenResponse.kt b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/auth/JwtBearerAccessTokenResponse.kt new file mode 100644 index 000000000..bc5ed1685 --- /dev/null +++ b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/auth/JwtBearerAccessTokenResponse.kt @@ -0,0 +1,43 @@ +package io.modelcontextprotocol.kotlin.sdk.client.auth + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlin.time.ComparableTimeMark + +/** + * OAuth 2.0 access token response returned by the MCP authorization server after a + * successful RFC 7523 JWT Bearer grant exchange. + * + * This is the result of Step 2 in the Enterprise Managed Authorization (SEP-990) flow: + * exchanging the JWT Authorization Grant (ID-JAG) for an access token at the MCP + * authorization server. + * + * The [expiresAt] field is **not serialized** — it is computed from [expiresIn] by + * [EnterpriseAuth.exchangeJwtBearerGrant] immediately after deserialization using a + * monotonic time mark. + * + * @see RFC 7523 + */ +@Serializable +public class JwtBearerAccessTokenResponse( + @SerialName("access_token") public val accessToken: String? = null, + @SerialName("token_type") public val tokenType: String? = null, + /** Lifetime of the access token in seconds, as reported by the authorization server. */ + @SerialName("expires_in") public val expiresIn: Int? = null, + public val scope: String? = null, + @SerialName("refresh_token") public val refreshToken: String? = null, +) { + /** + * Absolute expiry time computed from [expiresIn] after deserialization. Not serialized. + * + * Set by [EnterpriseAuth.exchangeJwtBearerGrant] using [kotlin.time.TimeSource.Monotonic]. + */ + public var expiresAt: ComparableTimeMark? = null + internal set + + /** + * Returns `true` if this token has passed its [expiresAt] mark, or `false` if + * no expiry information was provided by the server. + */ + public fun isExpired(): Boolean = expiresAt?.hasPassedNow() ?: false +} diff --git a/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/auth/RequestJwtAuthGrantOptions.kt b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/auth/RequestJwtAuthGrantOptions.kt new file mode 100644 index 000000000..634e87656 --- /dev/null +++ b/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/auth/RequestJwtAuthGrantOptions.kt @@ -0,0 +1,28 @@ +package io.modelcontextprotocol.kotlin.sdk.client.auth + +/** + * Options for [EnterpriseAuth.requestJwtAuthorizationGrant] — performs Step 1 of the + * Enterprise Managed Authorization (SEP-990) flow using a known token endpoint. + * + * Posts an RFC 8693 token exchange request to the enterprise IdP's token endpoint and + * returns the JAG (JWT Authorization Grant / ID-JAG token). + * + * @param tokenEndpoint The full URL of the enterprise IdP's token endpoint. + * @param idToken The OIDC ID token issued by the enterprise IdP. + * @param clientId The OAuth 2.0 client ID registered at the enterprise IdP. + * @param clientSecret The OAuth 2.0 client secret (optional; `null` for public clients). + * @param audience Optional `audience` parameter for the token exchange request. + * @param resource Optional `resource` parameter for the token exchange request. + * @param scope Optional `scope` parameter for the token exchange request. + * + * @see RFC 8693 + */ +public data class RequestJwtAuthGrantOptions( + public val tokenEndpoint: String, + public val idToken: String, + public val clientId: String, + public val clientSecret: String? = null, + public val audience: String? = null, + public val resource: String? = null, + public val scope: String? = null, +) diff --git a/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/auth/EnterpriseAuthProviderTest.kt b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/auth/EnterpriseAuthProviderTest.kt new file mode 100644 index 000000000..0ba2f3908 --- /dev/null +++ b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/auth/EnterpriseAuthProviderTest.kt @@ -0,0 +1,453 @@ +package io.modelcontextprotocol.kotlin.sdk.client.auth + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.string.shouldContain +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.MockRequestHandleScope +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.plugin +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.time.Duration.Companion.seconds + +/** + * Tests for [EnterpriseAuthProvider] — the Ktor plugin that handles the full enterprise + * auth flow, caching, and header injection. + */ +class EnterpriseAuthProviderTest { + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private val jsonContentTypeHeader = headersOf(HttpHeaders.ContentType, "application/json") + + private fun MockRequestHandleScope.jsonOk(body: String) = + respond(body, HttpStatusCode.OK, jsonContentTypeHeader) + + private fun MockRequestHandleScope.serverError() = + respond("Internal Server Error", HttpStatusCode.InternalServerError) + + /** Builds a standard mock auth engine (discovery + token exchange) for provider tests. */ + private fun buildMockAuthEngine( + tokenEndpointCallTracker: MutableList = mutableListOf(), + accessTokenValue: String = "mcp-access-token", + ) = MockEngine { request -> + when (request.url.encodedPath) { + "/.well-known/oauth-authorization-server" -> + jsonOk("""{"issuer":"https://auth.example.com","token_endpoint":"https://auth.example.com/token"}""") + "/token" -> { + tokenEndpointCallTracker.add(tokenEndpointCallTracker.size + 1) + jsonOk("""{"access_token":"$accessTokenValue","token_type":"Bearer","expires_in":3600}""") + } + else -> error("Unexpected auth request: ${request.url}") + } + } + + // ----------------------------------------------------------------------- + // Plugin integration — header injection and token caching + // ----------------------------------------------------------------------- + + @Test + fun `provider injects Authorization Bearer header into request`() = runTest { + val capturedAuth = mutableListOf() + val mcpClient = HttpClient(MockEngine { request -> + capturedAuth += request.headers[HttpHeaders.Authorization] ?: "" + respond("OK", HttpStatusCode.OK) + }) { + install(EnterpriseAuthProvider) { + clientId = "test-client" + assertionCallback = { _ -> "test-jag" } + authHttpClient = HttpClient(buildMockAuthEngine()) + } + } + + mcpClient.get("http://mcp.example.com/messages") + + capturedAuth shouldBe listOf("Bearer mcp-access-token") + } + + @Test + fun `provider caches token across multiple requests`() = runTest { + val tokenEndpointCalls = mutableListOf() + val mcpClient = HttpClient(MockEngine { respond("OK", HttpStatusCode.OK) }) { + install(EnterpriseAuthProvider) { + clientId = "client" + assertionCallback = { _ -> "jag" } + authHttpClient = HttpClient(buildMockAuthEngine(tokenEndpointCalls)) + } + } + + mcpClient.get("http://mcp.example.com/messages") + mcpClient.get("http://mcp.example.com/messages") + mcpClient.get("http://mcp.example.com/messages") + + tokenEndpointCalls.size shouldBe 1 + } + + @Test + fun `invalidateCache forces re-fetch on next request`() = runTest { + var tokenCounter = 0 + val capturedAuth = mutableListOf() + + val authEngine = MockEngine { request -> + when (request.url.encodedPath) { + "/.well-known/oauth-authorization-server" -> + jsonOk("""{"token_endpoint":"https://auth.example.com/token"}""") + "/token" -> { + tokenCounter++ + jsonOk("""{"access_token":"token-$tokenCounter","token_type":"Bearer","expires_in":3600}""") + } + else -> error("Unexpected: ${request.url}") + } + } + + val mcpClient = HttpClient(MockEngine { request -> + capturedAuth += request.headers[HttpHeaders.Authorization] ?: "" + respond("OK", HttpStatusCode.OK) + }) { + install(EnterpriseAuthProvider) { + clientId = "client" + assertionCallback = { _ -> "jag" } + authHttpClient = HttpClient(authEngine) + } + } + + mcpClient.get("http://mcp.example.com/messages") // fetches token-1 + + val provider = mcpClient.plugin(EnterpriseAuthProvider) + provider.invalidateCache() + + mcpClient.get("http://mcp.example.com/messages") // fetches token-2 + + tokenCounter shouldBe 2 + capturedAuth[0] shouldBe "Bearer token-1" + capturedAuth[1] shouldBe "Bearer token-2" + } + + // ----------------------------------------------------------------------- + // Plugin integration — error propagation + // ----------------------------------------------------------------------- + + @Test + fun `provider discovery failure propagates as EnterpriseAuthException`() = runTest { + val authEngine = MockEngine { request -> + when (request.url.encodedPath) { + "/.well-known/oauth-authorization-server" -> serverError() + "/.well-known/openid-configuration" -> serverError() + else -> error("Unexpected: ${request.url}") + } + } + val mcpClient = HttpClient(MockEngine { respond("OK", HttpStatusCode.OK) }) { + install(EnterpriseAuthProvider) { + clientId = "client" + assertionCallback = { _ -> "jag" } + authHttpClient = HttpClient(authEngine) + } + } + + shouldThrow { + mcpClient.get("http://mcp.example.com/messages") + } + } + + @Test + fun `provider assertion callback error propagates`() = runTest { + val authEngine = MockEngine { request -> + when (request.url.encodedPath) { + "/.well-known/oauth-authorization-server" -> + jsonOk("""{"token_endpoint":"https://auth.example.com/token"}""") + else -> error("Unexpected: ${request.url}") + } + } + val mcpClient = HttpClient(MockEngine { respond("OK", HttpStatusCode.OK) }) { + install(EnterpriseAuthProvider) { + clientId = "client" + assertionCallback = { _ -> throw RuntimeException("callback failed") } + authHttpClient = HttpClient(authEngine) + } + } + + shouldThrow { + mcpClient.get("http://mcp.example.com/messages") + }.message shouldBe "callback failed" + } + + // ----------------------------------------------------------------------- + // Plugin integration — assertion context + // ----------------------------------------------------------------------- + + @Test + fun `provider passes correct resourceUrl and authorizationServerUrl to callback`() = runTest { + var capturedContext: EnterpriseAuthAssertionContext? = null + + val authEngine = MockEngine { request -> + when (request.url.encodedPath) { + "/.well-known/oauth-authorization-server" -> + jsonOk("""{"issuer":"https://auth.example.com","token_endpoint":"https://auth.example.com/token"}""") + "/token" -> + jsonOk("""{"access_token":"token","token_type":"Bearer","expires_in":3600}""") + else -> error("Unexpected: ${request.url}") + } + } + val mcpClient = HttpClient(MockEngine { respond("OK", HttpStatusCode.OK) }) { + install(EnterpriseAuthProvider) { + clientId = "client" + assertionCallback = { ctx -> + capturedContext = ctx + "jag" + } + authHttpClient = HttpClient(authEngine) + } + } + + mcpClient.get("http://mcp.example.com/messages") + + capturedContext shouldNotBe null + capturedContext!!.resourceUrl shouldBe "http://mcp.example.com" + capturedContext!!.authorizationServerUrl shouldBe "https://auth.example.com" + } + + // ----------------------------------------------------------------------- + // EnterpriseAuthProvider.prepare — options validation + // ----------------------------------------------------------------------- + + @Test + fun `prepare throws when clientId is null`() { + shouldThrow { + EnterpriseAuthProvider.prepare { + assertionCallback = { _ -> "jag" } + authHttpClient = HttpClient(MockEngine { respond("OK", HttpStatusCode.OK) }) + // clientId not set + } + }.message shouldContain "clientId" + } + + @Test + fun `prepare throws when assertionCallback is null`() { + shouldThrow { + EnterpriseAuthProvider.prepare { + clientId = "my-client" + authHttpClient = HttpClient(MockEngine { respond("OK", HttpStatusCode.OK) }) + // assertionCallback not set + } + }.message shouldContain "assertionCallback" + } + + @Test + fun `provider throws when assertionCallback returns blank string`() = runTest { + val authEngine = MockEngine { request -> + when (request.url.encodedPath) { + "/.well-known/oauth-authorization-server" -> + jsonOk("""{"token_endpoint":"https://auth.example.com/token"}""") + else -> error("Unexpected: ${request.url}") + } + } + val mcpClient = HttpClient(MockEngine { respond("OK", HttpStatusCode.OK) }) { + install(EnterpriseAuthProvider) { + clientId = "client" + assertionCallback = { _ -> " " } // blank + authHttpClient = HttpClient(authEngine) + } + } + shouldThrow { + mcpClient.get("http://mcp.example.com/messages") + }.message shouldContain "blank" + } + + @Test + fun `provider token without expiresIn is cached indefinitely`() = runTest { + // When the server omits expires_in the token has no expiry and is kept + // in cache indefinitely (until explicitly invalidated). + val tokenEndpointCalls = mutableListOf() + val authEngine = MockEngine { request -> + when (request.url.encodedPath) { + "/.well-known/oauth-authorization-server" -> + jsonOk("""{"token_endpoint":"https://auth.example.com/token"}""") + "/token" -> { + tokenEndpointCalls.add(tokenEndpointCalls.size + 1) + jsonOk("""{"access_token":"no-expiry-token","token_type":"Bearer"}""") + } + else -> error("Unexpected: ${request.url}") + } + } + val mcpClient = HttpClient(MockEngine { respond("OK", HttpStatusCode.OK) }) { + install(EnterpriseAuthProvider) { + clientId = "client" + assertionCallback = { _ -> "jag" } + authHttpClient = HttpClient(authEngine) + } + } + + mcpClient.get("http://mcp.example.com/messages") + mcpClient.get("http://mcp.example.com/messages") + mcpClient.get("http://mcp.example.com/messages") + + tokenEndpointCalls.size shouldBe 1 + } + + @Test + fun `provider throws when discovery returns no token endpoint`() = runTest { + // Both well-known endpoints return metadata without a token_endpoint. + val authEngine = MockEngine { request -> + when (request.url.encodedPath) { + "/.well-known/oauth-authorization-server" -> + jsonOk("""{}""") + "/.well-known/openid-configuration" -> + jsonOk("""{"issuer":"https://auth.example.com"}""") + else -> error("Unexpected: ${request.url}") + } + } + val mcpClient = HttpClient(MockEngine { respond("OK", HttpStatusCode.OK) }) { + install(EnterpriseAuthProvider) { + clientId = "client" + assertionCallback = { _ -> "jag" } + authHttpClient = HttpClient(authEngine) + } + } + val ex = shouldThrow { + mcpClient.get("http://mcp.example.com/messages") + } + ex.message shouldContain "token_endpoint" + } + + @Test + fun `provider uses resource base URL as authorizationServerUrl when issuer is absent`() = runTest { + var capturedContext: EnterpriseAuthAssertionContext? = null + val authEngine = MockEngine { request -> + when (request.url.encodedPath) { + // Metadata without issuer — provider should fall back to resourceBaseUrl + "/.well-known/oauth-authorization-server" -> + jsonOk("""{"token_endpoint":"https://auth.example.com/token"}""") + "/token" -> + jsonOk("""{"access_token":"tok","token_type":"Bearer","expires_in":3600}""") + else -> error("Unexpected: ${request.url}") + } + } + val mcpClient = HttpClient(MockEngine { respond("OK", HttpStatusCode.OK) }) { + install(EnterpriseAuthProvider) { + clientId = "client" + assertionCallback = { ctx -> + capturedContext = ctx + "jag" + } + authHttpClient = HttpClient(authEngine) + } + } + + mcpClient.get("http://mcp.example.com/messages") + + capturedContext shouldNotBe null + // No issuer in metadata → authorizationServerUrl falls back to resource base URL + capturedContext!!.authorizationServerUrl shouldBe "http://mcp.example.com" + } + + @Test + fun `provider refreshes token when it is near expiry`() = runTest { + val tokenEndpointCalls = mutableListOf() + val authEngine = MockEngine { request -> + when (request.url.encodedPath) { + "/.well-known/oauth-authorization-server" -> + jsonOk("""{"token_endpoint":"https://auth.example.com/token"}""") + "/token" -> { + tokenEndpointCalls.add(tokenEndpointCalls.size + 1) + // expires_in=1s — token is near-expiry relative to a 90s buffer + jsonOk("""{"access_token":"tok","token_type":"Bearer","expires_in":1}""") + } + else -> error("Unexpected: ${request.url}") + } + } + val mcpClient = HttpClient(MockEngine { respond("OK", HttpStatusCode.OK) }) { + install(EnterpriseAuthProvider) { + clientId = "client" + assertionCallback = { _ -> "jag" } + authHttpClient = HttpClient(authEngine) + // 90s buffer >> 1s expires_in → every request sees the cached token as near-expiry + expiryBuffer = 90.seconds + } + } + + mcpClient.get("http://mcp.example.com/messages") + mcpClient.get("http://mcp.example.com/messages") + + // Each request should have fetched a fresh token since cached one is always near-expiry + tokenEndpointCalls.size shouldBe 2 + } + + @Test + fun `provider respects custom expiryBuffer`() = runTest { + val tokenEndpointCalls = mutableListOf() + val authEngine = MockEngine { request -> + when (request.url.encodedPath) { + "/.well-known/oauth-authorization-server" -> + jsonOk("""{"token_endpoint":"https://auth.example.com/token"}""") + "/token" -> { + tokenEndpointCalls.add(tokenEndpointCalls.size + 1) + // expires_in=1 with a 0s buffer — token should be cached until truly expired + jsonOk("""{"access_token":"token","token_type":"Bearer","expires_in":1}""") + } + else -> error("Unexpected: ${request.url}") + } + } + val mcpClient = HttpClient(MockEngine { respond("OK", HttpStatusCode.OK) }) { + install(EnterpriseAuthProvider) { + clientId = "client" + assertionCallback = { _ -> "jag" } + authHttpClient = HttpClient(authEngine) + expiryBuffer = 0.seconds // no buffer — token is valid until it actually expires + } + } + // Both requests should reuse the cached token (it hasn't actually expired yet) + mcpClient.get("http://mcp.example.com/messages") + mcpClient.get("http://mcp.example.com/messages") + + tokenEndpointCalls.size shouldBe 1 + } + + // ----------------------------------------------------------------------- + // Full end-to-end via plugin — header propagated correctly + // ----------------------------------------------------------------------- + + @Test + fun `full flow via plugin - Authorization header set and token is reused`() = runTest { + val authEngine = MockEngine { request -> + when (request.url.encodedPath) { + "/.well-known/oauth-authorization-server" -> + jsonOk("""{"token_endpoint":"https://idp.example.com/token"}""") + "/token" -> + jsonOk("""{"access_token":"final-access-token","token_type":"Bearer","expires_in":3600}""") + else -> error("Unexpected: ${request.url}") + } + } + + val capturedAuthHeaders = mutableListOf() + val mcpEngine = MockEngine { request -> + capturedAuthHeaders += request.headers[HttpHeaders.Authorization] ?: "" + respond("OK", HttpStatusCode.OK) + } + + val mcpClient = HttpClient(mcpEngine) { + install(EnterpriseAuthProvider) { + clientId = "mcp-client" + assertionCallback = { _ -> "enterprise-jag" } + authHttpClient = HttpClient(authEngine) + } + } + + val response1 = mcpClient.get("http://mcp.example.com/sse") + val response2 = mcpClient.get("http://mcp.example.com/sse") + + response1.bodyAsText() shouldBe "OK" + response2.bodyAsText() shouldBe "OK" + capturedAuthHeaders.all { it == "Bearer final-access-token" }.shouldBeTrue() + } +} diff --git a/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/auth/EnterpriseAuthTest.kt b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/auth/EnterpriseAuthTest.kt new file mode 100644 index 000000000..f2c40db18 --- /dev/null +++ b/kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/auth/EnterpriseAuthTest.kt @@ -0,0 +1,462 @@ +package io.modelcontextprotocol.kotlin.sdk.client.auth + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.booleans.shouldBeFalse +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldNotContain +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.MockRequestHandleScope +import io.ktor.client.engine.mock.respond +import io.ktor.client.request.HttpResponseData +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.content.TextContent +import io.ktor.http.headersOf +import io.ktor.util.decodeBase64Bytes +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeSource + +/** + * Tests for [EnterpriseAuth] — discovery, JAG request, and token exchange utilities. + */ +class EnterpriseAuthTest { + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private val jsonContentTypeHeader = headersOf(HttpHeaders.ContentType, "application/json") + + private fun MockRequestHandleScope.jsonOk(body: String) = + respond(body, HttpStatusCode.OK, jsonContentTypeHeader) + + private fun MockRequestHandleScope.serverError() = + respond("Internal Server Error", HttpStatusCode.InternalServerError) + + /** Builds a mock [HttpClient] that dispatches by URL path. */ + private fun mockHttpClient( + vararg handlers: Pair HttpResponseData>, + ): HttpClient { + val handlerMap = handlers.toMap() + return HttpClient(MockEngine { request -> + val path = request.url.encodedPath + val handler = handlerMap[path] ?: error("Unexpected request to path: $path") + handler() + }) + } + + private fun requestBodyText(request: io.ktor.client.request.HttpRequestData): String = + (request.body as TextContent).text + + // ----------------------------------------------------------------------- + // discoverAuthServerMetadata — success paths + // ----------------------------------------------------------------------- + + @Test + fun `discoverAuthServerMetadata success via oauth well-known`() = runTest { + val httpClient = mockHttpClient( + "/.well-known/oauth-authorization-server" to { + jsonOk( + """{"issuer":"https://auth.example.com","token_endpoint":"https://auth.example.com/token","authorization_endpoint":"https://auth.example.com/authorize"}""", + ) + }, + ) + val metadata = EnterpriseAuth.discoverAuthServerMetadata("https://auth.example.com", httpClient) + metadata.issuer shouldBe "https://auth.example.com" + metadata.tokenEndpoint shouldBe "https://auth.example.com/token" + metadata.authorizationEndpoint shouldBe "https://auth.example.com/authorize" + } + + @Test + fun `discoverAuthServerMetadata falls back to openid-configuration on 404`() = runTest { + val httpClient = mockHttpClient( + "/.well-known/oauth-authorization-server" to { respond("", HttpStatusCode.NotFound) }, + "/.well-known/openid-configuration" to { + jsonOk("""{"issuer":"https://idp.example.com","token_endpoint":"https://idp.example.com/token"}""") + }, + ) + val metadata = EnterpriseAuth.discoverAuthServerMetadata("https://idp.example.com", httpClient) + metadata.tokenEndpoint shouldBe "https://idp.example.com/token" + } + + @Test + fun `discoverAuthServerMetadata falls back to openid-configuration on 500`() = runTest { + val httpClient = mockHttpClient( + "/.well-known/oauth-authorization-server" to { serverError() }, + "/.well-known/openid-configuration" to { + jsonOk("""{"issuer":"https://idp.example.com","token_endpoint":"https://idp.example.com/token"}""") + }, + ) + val metadata = EnterpriseAuth.discoverAuthServerMetadata("https://idp.example.com", httpClient) + metadata.tokenEndpoint shouldBe "https://idp.example.com/token" + } + + @Test + fun `discoverAuthServerMetadata both endpoints fail throws EnterpriseAuthException`() = runTest { + val httpClient = mockHttpClient( + "/.well-known/oauth-authorization-server" to { serverError() }, + "/.well-known/openid-configuration" to { serverError() }, + ) + val ex = shouldThrow { + EnterpriseAuth.discoverAuthServerMetadata("https://auth.example.com", httpClient) + } + ex.message shouldContain "HTTP 500" + } + + @Test + fun `discoverAuthServerMetadata strips trailing slash from url`() = runTest { + val httpClient = mockHttpClient( + "/.well-known/oauth-authorization-server" to { + jsonOk("""{"issuer":"https://auth.example.com","token_endpoint":"https://auth.example.com/token"}""") + }, + ) + val metadata = EnterpriseAuth.discoverAuthServerMetadata("https://auth.example.com/", httpClient) + metadata.issuer shouldBe "https://auth.example.com" + } + + // ----------------------------------------------------------------------- + // requestJwtAuthorizationGrant — success and validation + // ----------------------------------------------------------------------- + + @Test + fun `requestJwtAuthorizationGrant success`() = runTest { + val httpClient = HttpClient(MockEngine { request -> + val body = requestBodyText(request) + body shouldContain "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange" + body shouldContain "subject_token=my-id-token" + body shouldContain "subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aid_token" + body shouldContain "requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aid-jag" + body shouldContain "client_id=my-client" + jsonOk( + """{"access_token":"my-jag-token","issued_token_type":"urn:ietf:params:oauth:token-type:id-jag","token_type":"N_A"}""", + ) + }) + + val jag = EnterpriseAuth.requestJwtAuthorizationGrant( + RequestJwtAuthGrantOptions( + tokenEndpoint = "https://idp.example.com/token", + idToken = "my-id-token", + clientId = "my-client", + ), + httpClient, + ) + jag shouldBe "my-jag-token" + } + + @Test + fun `requestJwtAuthorizationGrant includes optional params in body`() = runTest { + val httpClient = HttpClient(MockEngine { request -> + val body = requestBodyText(request) + body shouldContain "client_secret=s3cr3t" + body shouldContain "audience=my-audience" + body shouldContain "resource=https%3A%2F%2Fmcp.example.com" + body shouldContain "scope=read+write" + jsonOk( + """{"access_token":"jag","issued_token_type":"urn:ietf:params:oauth:token-type:id-jag","token_type":"N_A"}""", + ) + }) + + EnterpriseAuth.requestJwtAuthorizationGrant( + RequestJwtAuthGrantOptions( + tokenEndpoint = "https://idp.example.com/token", + idToken = "id-token", + clientId = "client", + clientSecret = "s3cr3t", + audience = "my-audience", + resource = "https://mcp.example.com", + scope = "read write", + ), + httpClient, + ) shouldBe "jag" + } + + @Test + fun `requestJwtAuthorizationGrant wrong issued_token_type throws`() = runTest { + val httpClient = mockHttpClient( + "/token" to { + jsonOk("""{"access_token":"jag","issued_token_type":"urn:wrong:type","token_type":"N_A"}""") + }, + ) + val ex = shouldThrow { + EnterpriseAuth.requestJwtAuthorizationGrant( + RequestJwtAuthGrantOptions("https://idp.example.com/token", "id", "client"), + httpClient, + ) + } + ex.message shouldContain "issued_token_type" + ex.message shouldContain "urn:wrong:type" + } + + @Test + fun `requestJwtAuthorizationGrant nonStandardTokenType succeeds`() = runTest { + // token_type is informational per RFC 8693 §2.2.1; non-N_A values must not be + // rejected so that conformant IdPs that omit or vary the field are accepted. + val httpClient = mockHttpClient( + "/token" to { + jsonOk( + """{"access_token":"jag","issued_token_type":"urn:ietf:params:oauth:token-type:id-jag","token_type":"Bearer"}""", + ) + }, + ) + EnterpriseAuth.requestJwtAuthorizationGrant( + RequestJwtAuthGrantOptions("https://idp.example.com/token", "id", "client"), + httpClient, + ) shouldBe "jag" + } + + @Test + fun `requestJwtAuthorizationGrant missing access_token throws`() = runTest { + val httpClient = mockHttpClient( + "/token" to { + jsonOk("""{"issued_token_type":"urn:ietf:params:oauth:token-type:id-jag","token_type":"N_A"}""") + }, + ) + val ex = shouldThrow { + EnterpriseAuth.requestJwtAuthorizationGrant( + RequestJwtAuthGrantOptions("https://idp.example.com/token", "id", "client"), + httpClient, + ) + } + ex.message shouldContain "access_token" + } + + @Test + fun `requestJwtAuthorizationGrant HTTP error throws`() = runTest { + val httpClient = mockHttpClient("/token" to { serverError() }) + val ex = shouldThrow { + EnterpriseAuth.requestJwtAuthorizationGrant( + RequestJwtAuthGrantOptions("https://idp.example.com/token", "id", "client"), + httpClient, + ) + } + ex.message shouldContain "HTTP 500" + } + + // ----------------------------------------------------------------------- + // discoverAndRequestJwtAuthorizationGrant + // ----------------------------------------------------------------------- + + @Test + fun `discoverAndRequestJwtAuthorizationGrant full discovery flow`() = runTest { + val httpClient = mockHttpClient( + "/.well-known/oauth-authorization-server" to { + jsonOk("""{"token_endpoint":"https://idp.example.com/token"}""") + }, + "/token" to { + jsonOk( + """{"access_token":"the-jag","issued_token_type":"urn:ietf:params:oauth:token-type:id-jag","token_type":"N_A"}""", + ) + }, + ) + val jag = EnterpriseAuth.discoverAndRequestJwtAuthorizationGrant( + DiscoverAndRequestJwtAuthGrantOptions( + idpUrl = "https://idp.example.com", + idToken = "my-id-token", + clientId = "my-client", + ), + httpClient, + ) + jag shouldBe "the-jag" + } + + @Test + fun `discoverAndRequestJwtAuthorizationGrant skips discovery when idpTokenEndpoint provided`() = runTest { + var discoveryCallCount = 0 + val httpClient = HttpClient(MockEngine { request -> + if (request.url.encodedPath.contains(".well-known")) discoveryCallCount++ + jsonOk( + """{"access_token":"the-jag","issued_token_type":"urn:ietf:params:oauth:token-type:id-jag","token_type":"N_A"}""", + ) + }) + + EnterpriseAuth.discoverAndRequestJwtAuthorizationGrant( + DiscoverAndRequestJwtAuthGrantOptions( + idpUrl = "https://idp.example.com", + idToken = "my-id-token", + clientId = "my-client", + idpTokenEndpoint = "https://idp.example.com/custom-token", + ), + httpClient, + ) shouldBe "the-jag" + + discoveryCallCount shouldBe 0 + } + + @Test + fun `discoverAndRequestJwtAuthorizationGrant no token_endpoint in metadata throws`() = runTest { + val httpClient = mockHttpClient( + "/.well-known/oauth-authorization-server" to { + jsonOk("""{"issuer":"https://idp.example.com"}""") // no token_endpoint + }, + ) + val ex = shouldThrow { + EnterpriseAuth.discoverAndRequestJwtAuthorizationGrant( + DiscoverAndRequestJwtAuthGrantOptions( + idpUrl = "https://idp.example.com", + idToken = "id", + clientId = "client", + ), + httpClient, + ) + } + ex.message shouldContain "token_endpoint" + } + + // ----------------------------------------------------------------------- + // exchangeJwtBearerGrant + // ----------------------------------------------------------------------- + + @Test + fun `exchangeJwtBearerGrant success with expiresAt computed`() = runTest { + val httpClient = HttpClient(MockEngine { request -> + val body = requestBodyText(request) + body shouldContain "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer" + body shouldContain "assertion=my-jag" + // client credentials must be in Basic Auth header, NOT in the request body + body shouldNotContain "client_id" + val authHeader = request.headers[HttpHeaders.Authorization] + authHeader shouldNotBe null + authHeader!! shouldContain "Basic " + val decoded = authHeader.removePrefix("Basic ").decodeBase64Bytes().decodeToString() + decoded shouldBe "my-client:" + jsonOk( + """{"access_token":"access-token-123","token_type":"Bearer","expires_in":3600}""", + ) + }) + + val markBefore = TimeSource.Monotonic.markNow() + val tokenResponse = EnterpriseAuth.exchangeJwtBearerGrant( + ExchangeJwtBearerGrantOptions( + tokenEndpoint = "https://auth.example.com/token", + assertion = "my-jag", + clientId = "my-client", + ), + httpClient, + ) + + tokenResponse.accessToken shouldBe "access-token-123" + tokenResponse.tokenType shouldBe "Bearer" + tokenResponse.expiresIn shouldBe 3600 + tokenResponse.expiresAt shouldNotBe null + tokenResponse.isExpired().shouldBeFalse() + + // expiresAt should be approximately 1 hour (3600s) from now; at minimum 59 minutes away + val fiftyNineMinutesFromNow = markBefore + 59.minutes + tokenResponse.expiresAt!!.minus(fiftyNineMinutesFromNow).isPositive().shouldBeTrue() + } + + @Test + fun `exchangeJwtBearerGrant missing access_token throws`() = runTest { + val httpClient = mockHttpClient( + "/token" to { jsonOk("""{"token_type":"Bearer","expires_in":3600}""") }, + ) + val ex = shouldThrow { + EnterpriseAuth.exchangeJwtBearerGrant( + ExchangeJwtBearerGrantOptions("https://auth.example.com/token", "jag", "client"), + httpClient, + ) + } + ex.message shouldContain "access_token" + } + + @Test + fun `exchangeJwtBearerGrant HTTP error throws`() = runTest { + val httpClient = mockHttpClient("/token" to { serverError() }) + val ex = shouldThrow { + EnterpriseAuth.exchangeJwtBearerGrant( + ExchangeJwtBearerGrantOptions("https://auth.example.com/token", "jag", "client"), + httpClient, + ) + } + ex.message shouldContain "HTTP 500" + } + + // ----------------------------------------------------------------------- + // Serialization coverage — all fields of the response models + // ----------------------------------------------------------------------- + + @Test + fun `JagTokenExchangeResponse deserializes scope and expiresIn`() = runTest { + val httpClient = mockHttpClient( + "/token" to { + jsonOk( + """{"access_token":"jag","issued_token_type":"urn:ietf:params:oauth:token-type:id-jag","token_type":"N_A","scope":"read write","expires_in":600}""", + ) + }, + ) + // Run through requestJwtAuthorizationGrant so the response model is exercised + EnterpriseAuth.requestJwtAuthorizationGrant( + RequestJwtAuthGrantOptions( + tokenEndpoint = "https://idp.example.com/token", + idToken = "id", + clientId = "client", + ), + httpClient, + ) shouldBe "jag" + } + + @Test + fun `AuthServerMetadata deserializes jwksUri`() = runTest { + val httpClient = mockHttpClient( + "/.well-known/oauth-authorization-server" to { + jsonOk( + """{"issuer":"https://auth.example.com","token_endpoint":"https://auth.example.com/token","authorization_endpoint":"https://auth.example.com/authorize","jwks_uri":"https://auth.example.com/jwks"}""", + ) + }, + ) + val metadata = EnterpriseAuth.discoverAuthServerMetadata("https://auth.example.com", httpClient) + metadata.jwksUri shouldBe "https://auth.example.com/jwks" + } + + @Test + fun `JwtBearerAccessTokenResponse deserializes scope and refreshToken`() = runTest { + val httpClient = mockHttpClient( + "/token" to { + jsonOk( + """{"access_token":"at","token_type":"Bearer","expires_in":3600,"scope":"mcp:read","refresh_token":"rt-ignored"}""", + ) + }, + ) + val response = EnterpriseAuth.exchangeJwtBearerGrant( + ExchangeJwtBearerGrantOptions( + tokenEndpoint = "https://auth.example.com/token", + assertion = "jag", + clientId = "client", + ), + httpClient, + ) + response.scope shouldBe "mcp:read" + response.refreshToken shouldBe "rt-ignored" + } + + // ----------------------------------------------------------------------- + // JwtBearerAccessTokenResponse.isExpired + // ----------------------------------------------------------------------- + + @Test + fun `isExpired returns false when expiresAt is null`() { + val response = JwtBearerAccessTokenResponse(accessToken = "token") + response.isExpired().shouldBeFalse() + } + + @Test + fun `isExpired returns false when expiresAt is in the future`() { + val response = JwtBearerAccessTokenResponse(accessToken = "token") + response.expiresAt = TimeSource.Monotonic.markNow() + 3600.seconds + response.isExpired().shouldBeFalse() + } + + @Test + fun `isExpired returns true when expiresAt has passed`() { + val response = JwtBearerAccessTokenResponse(accessToken = "token") + response.expiresAt = TimeSource.Monotonic.markNow() - 1.seconds + response.isExpired().shouldBeTrue() + } +}