From 665ed51b9cde8d0d7b8a9785930acdf78d46f159 Mon Sep 17 00:00:00 2001 From: Prachi Pandey Date: Mon, 2 Mar 2026 11:08:20 +0530 Subject: [PATCH 1/4] feat: Add Enterprise Managed Authorization (SEP-990) support --- .../client/auth/AuthServerMetadata.java | 66 +++ ...DiscoverAndRequestJwtAuthGrantOptions.java | 165 ++++++ .../client/auth/EnterpriseAuth.java | 341 +++++++++++ .../auth/EnterpriseAuthAssertionContext.java | 52 ++ .../client/auth/EnterpriseAuthException.java | 32 + .../client/auth/EnterpriseAuthProvider.java | 205 +++++++ .../auth/EnterpriseAuthProviderOptions.java | 115 ++++ .../auth/ExchangeJwtBearerGrantOptions.java | 115 ++++ .../client/auth/JagTokenExchangeResponse.java | 87 +++ .../auth/JwtBearerAccessTokenResponse.java | 106 ++++ .../auth/RequestJwtAuthGrantOptions.java | 143 +++++ .../client/auth/EnterpriseAuthTest.java | 557 ++++++++++++++++++ .../json/gson/GsonMcpJsonMapperSupplier.java | 51 ++ ...contextprotocol.json.McpJsonMapperSupplier | 1 + 14 files changed, 2036 insertions(+) create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/client/auth/AuthServerMetadata.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/client/auth/DiscoverAndRequestJwtAuthGrantOptions.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuth.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthAssertionContext.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthException.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthProvider.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthProviderOptions.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/client/auth/ExchangeJwtBearerGrantOptions.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/client/auth/JagTokenExchangeResponse.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/client/auth/JwtBearerAccessTokenResponse.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/client/auth/RequestJwtAuthGrantOptions.java create mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/client/auth/EnterpriseAuthTest.java create mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/spec/json/gson/GsonMcpJsonMapperSupplier.java create mode 100644 mcp-core/src/test/resources/META-INF/services/io.modelcontextprotocol.json.McpJsonMapperSupplier diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/AuthServerMetadata.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/AuthServerMetadata.java new file mode 100644 index 000000000..321943a41 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/AuthServerMetadata.java @@ -0,0 +1,66 @@ +/* + * Copyright 2024-2025 the original author or authors. + */ + +package io.modelcontextprotocol.client.auth; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * OAuth 2.0 Authorization Server Metadata as defined by RFC 8414. + *

+ * Used during Enterprise Managed Authorization (SEP-990) to discover the token endpoint + * of the enterprise Identity Provider and the MCP authorization server. + * + * @author MCP SDK Contributors + * @see RFC 8414 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class AuthServerMetadata { + + @JsonProperty("issuer") + private String issuer; + + @JsonProperty("token_endpoint") + private String tokenEndpoint; + + @JsonProperty("authorization_endpoint") + private String authorizationEndpoint; + + @JsonProperty("jwks_uri") + private String jwksUri; + + public String getIssuer() { + return issuer; + } + + public void setIssuer(String issuer) { + this.issuer = issuer; + } + + public String getTokenEndpoint() { + return tokenEndpoint; + } + + public void setTokenEndpoint(String tokenEndpoint) { + this.tokenEndpoint = tokenEndpoint; + } + + public String getAuthorizationEndpoint() { + return authorizationEndpoint; + } + + public void setAuthorizationEndpoint(String authorizationEndpoint) { + this.authorizationEndpoint = authorizationEndpoint; + } + + public String getJwksUri() { + return jwksUri; + } + + public void setJwksUri(String jwksUri) { + this.jwksUri = jwksUri; + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/DiscoverAndRequestJwtAuthGrantOptions.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/DiscoverAndRequestJwtAuthGrantOptions.java new file mode 100644 index 000000000..bb693dba4 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/DiscoverAndRequestJwtAuthGrantOptions.java @@ -0,0 +1,165 @@ +/* + * Copyright 2024-2025 the original author or authors. + */ + +package io.modelcontextprotocol.client.auth; + +import java.util.Objects; + +/** + * Options for {@link EnterpriseAuth#discoverAndRequestJwtAuthorizationGrant} — performs + * step 1 of the Enterprise Managed Authorization (SEP-990) flow by first discovering the + * IdP token endpoint via RFC 8414 metadata discovery, then requesting the JAG. + *

+ * If {@link #idpTokenEndpoint} is provided it is used directly and discovery is skipped. + * + * @author MCP SDK Contributors + */ +public class DiscoverAndRequestJwtAuthGrantOptions { + + /** + * The base URL of the enterprise IdP. Used as the root URL for RFC 8414 discovery + * ({@code /.well-known/oauth-authorization-server} or + * {@code /.well-known/openid-configuration}). + */ + private final String idpUrl; + + /** + * Optional override for the IdP's token endpoint. When provided, RFC 8414 discovery + * is skipped. + */ + private final String idpTokenEndpoint; + + /** The ID token (assertion) issued by the enterprise IdP. */ + private final String idToken; + + /** The OAuth 2.0 client ID registered at the enterprise IdP. */ + private final String clientId; + + /** The OAuth 2.0 client secret (may be {@code null} for public clients). */ + private final String clientSecret; + + /** The {@code audience} parameter for the token exchange request (optional). */ + private final String audience; + + /** The {@code resource} parameter for the token exchange request (optional). */ + private final String resource; + + /** The {@code scope} parameter for the token exchange request (optional). */ + private final String scope; + + private DiscoverAndRequestJwtAuthGrantOptions(Builder builder) { + this.idpUrl = Objects.requireNonNull(builder.idpUrl, "idpUrl must not be null"); + this.idpTokenEndpoint = builder.idpTokenEndpoint; + this.idToken = Objects.requireNonNull(builder.idToken, "idToken must not be null"); + this.clientId = Objects.requireNonNull(builder.clientId, "clientId must not be null"); + this.clientSecret = builder.clientSecret; + this.audience = builder.audience; + this.resource = builder.resource; + this.scope = builder.scope; + } + + public String getIdpUrl() { + return idpUrl; + } + + public String getIdpTokenEndpoint() { + return idpTokenEndpoint; + } + + public String getIdToken() { + return idToken; + } + + public String getClientId() { + return clientId; + } + + public String getClientSecret() { + return clientSecret; + } + + public String getAudience() { + return audience; + } + + public String getResource() { + return resource; + } + + public String getScope() { + return scope; + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + + private String idpUrl; + + private String idpTokenEndpoint; + + private String idToken; + + private String clientId; + + private String clientSecret; + + private String audience; + + private String resource; + + private String scope; + + private Builder() { + } + + public Builder idpUrl(String idpUrl) { + this.idpUrl = idpUrl; + return this; + } + + public Builder idpTokenEndpoint(String idpTokenEndpoint) { + this.idpTokenEndpoint = idpTokenEndpoint; + return this; + } + + public Builder idToken(String idToken) { + this.idToken = idToken; + return this; + } + + public Builder clientId(String clientId) { + this.clientId = clientId; + return this; + } + + public Builder clientSecret(String clientSecret) { + this.clientSecret = clientSecret; + return this; + } + + public Builder audience(String audience) { + this.audience = audience; + return this; + } + + public Builder resource(String resource) { + this.resource = resource; + return this; + } + + public Builder scope(String scope) { + this.scope = scope; + return this; + } + + public DiscoverAndRequestJwtAuthGrantOptions build() { + return new DiscoverAndRequestJwtAuthGrantOptions(this); + } + + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuth.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuth.java new file mode 100644 index 000000000..fe18dd0c1 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuth.java @@ -0,0 +1,341 @@ +/* + * Copyright 2024-2025 the original author or authors. + */ + +package io.modelcontextprotocol.client.auth; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.json.McpJsonMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +/** + * Layer 2 utility class for the Enterprise Managed Authorization (SEP-990) flow. + *

+ * Provides static async methods 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. + * Methods: {@link #requestJwtAuthorizationGrant} / + * {@link #discoverAndRequestJwtAuthorizationGrant}.
  2. + *
  3. Step 2 — access token exchange: Exchange the JAG for an OAuth 2.0 access + * token at the MCP authorization server via RFC 7523 JWT Bearer grant. Method: + * {@link #exchangeJwtBearerGrant}.
  4. + *
+ *

+ * For a higher-level, stateful integration that handles both steps and caches the + * resulting access token, use {@link EnterpriseAuthProvider} instead. + *

+ * All methods return {@link Mono} and require a {@link java.net.http.HttpClient} to be + * provided by the caller. They do not manage the lifecycle of the client. + * + * @author MCP SDK Contributors + * @see EnterpriseAuthProvider + * @see RFC 8414 — Authorization + * Server Metadata + * @see RFC 8693 — Token + * Exchange + * @see RFC 7523 — JWT Bearer + * Grant + */ +public final class EnterpriseAuth { + + private static final Logger logger = LoggerFactory.getLogger(EnterpriseAuth.class); + + /** + * Token type URI for OIDC ID tokens, used as the {@code subject_token_type} in the + * RFC 8693 token exchange request. + */ + public static final String TOKEN_TYPE_ID_TOKEN = "urn:ietf:params:oauth:token-type:id_token"; + + /** + * Token type URI for JWT Authorization Grants (ID-JAG), used as the + * {@code requested_token_type} in the token exchange request and validated as the + * {@code issued_token_type} in the response. + */ + public static final String TOKEN_TYPE_ID_JAG = "urn:ietf:params:oauth:token-type:id-jag"; + + /** + * Grant type URI for RFC 8693 token exchange requests. + */ + public static final String GRANT_TYPE_TOKEN_EXCHANGE = "urn:ietf:params:oauth:grant-type:token-exchange"; + + /** + * Grant type URI for RFC 7523 JWT Bearer grant requests. + */ + public static final String GRANT_TYPE_JWT_BEARER = "urn:ietf:params:oauth:grant-type:jwt-bearer"; + + private static final String WELL_KNOWN_OAUTH = "/.well-known/oauth-authorization-server"; + + private static final String WELL_KNOWN_OPENID = "/.well-known/openid-configuration"; + + private EnterpriseAuth() { + } + + // ----------------------------------------------------------------------- + // 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 + * {@code {url}/.well-known/oauth-authorization-server}. If that fails (non-200 + * response or network error), falls back to + * {@code {url}/.well-known/openid-configuration}. + * @param url the base URL of the authorization server or resource server + * @param httpClient the HTTP client to use for the discovery request + * @return a {@link Mono} emitting the parsed {@link AuthServerMetadata}, or an error + * of type {@link EnterpriseAuthException} if discovery fails + */ + public static Mono discoverAuthServerMetadata(String url, HttpClient httpClient) { + String baseUrl = url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + String oauthDiscoveryUrl = baseUrl + WELL_KNOWN_OAUTH; + String openIdDiscoveryUrl = baseUrl + WELL_KNOWN_OPENID; + logger.debug("Discovering authorization server metadata for {}", baseUrl); + return fetchAuthServerMetadata(oauthDiscoveryUrl, httpClient) + .onErrorResume(e -> fetchAuthServerMetadata(openIdDiscoveryUrl, httpClient)); + } + + private static Mono fetchAuthServerMetadata(String url, HttpClient httpClient) { + return Mono.fromFuture(() -> { + HttpRequest request = HttpRequest.newBuilder(URI.create(url)) + .GET() + .header("Accept", "application/json") + .build(); + return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()); + }).flatMap(response -> { + if (response.statusCode() != 200) { + return Mono.error(new EnterpriseAuthException("Failed to discover authorization server metadata from " + + url + ": HTTP " + response.statusCode())); + } + try { + McpJsonMapper mapper = McpJsonDefaults.getMapper(); + AuthServerMetadata metadata = mapper.readValue(response.body(), AuthServerMetadata.class); + logger.debug("Discovered authorization server metadata from {}: issuer={}, tokenEndpoint={}", url, + metadata.getIssuer(), metadata.getTokenEndpoint()); + return Mono.just(metadata); + } + catch (Exception e) { + return Mono + .error(new 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 {@link #exchangeJwtBearerGrant}. + *

+ * Validates that the response {@code issued_token_type} equals + * {@link #TOKEN_TYPE_ID_JAG} and that {@code token_type} is {@code N_A} + * (case-insensitive) per RFC 8693 §2.2.1. + * @param options request parameters including the IdP token endpoint, ID token, and + * client credentials + * @param httpClient the HTTP client to use + * @return a {@link Mono} emitting the JAG (the {@code access_token} value from the + * exchange response), or an error of type {@link EnterpriseAuthException} + */ + public static Mono requestJwtAuthorizationGrant(RequestJwtAuthGrantOptions options, HttpClient httpClient) { + return Mono.defer(() -> { + List params = new ArrayList<>(); + params.add(encode("grant_type") + "=" + encode(GRANT_TYPE_TOKEN_EXCHANGE)); + params.add(encode("subject_token") + "=" + encode(options.getIdToken())); + params.add(encode("subject_token_type") + "=" + encode(TOKEN_TYPE_ID_TOKEN)); + params.add(encode("requested_token_type") + "=" + encode(TOKEN_TYPE_ID_JAG)); + params.add(encode("client_id") + "=" + encode(options.getClientId())); + if (options.getClientSecret() != null) { + params.add(encode("client_secret") + "=" + encode(options.getClientSecret())); + } + if (options.getAudience() != null) { + params.add(encode("audience") + "=" + encode(options.getAudience())); + } + if (options.getResource() != null) { + params.add(encode("resource") + "=" + encode(options.getResource())); + } + if (options.getScope() != null) { + params.add(encode("scope") + "=" + encode(options.getScope())); + } + String body = String.join("&", params); + logger.debug("Requesting JAG token exchange at {}", options.getTokenEndpoint()); + HttpRequest request = HttpRequest.newBuilder(URI.create(options.getTokenEndpoint())) + .POST(HttpRequest.BodyPublishers.ofString(body)) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Accept", "application/json") + .build(); + return Mono.fromFuture(() -> httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())); + }).flatMap(response -> { + if (response.statusCode() != 200) { + return Mono.error(new EnterpriseAuthException( + "JAG token exchange failed: HTTP " + response.statusCode() + " - " + response.body())); + } + try { + McpJsonMapper mapper = McpJsonDefaults.getMapper(); + JagTokenExchangeResponse tokenResponse = mapper.readValue(response.body(), + JagTokenExchangeResponse.class); + + // Validate per RFC 8693 §2.2.1 + if (!TOKEN_TYPE_ID_JAG.equalsIgnoreCase(tokenResponse.getIssuedTokenType())) { + return Mono.error(new EnterpriseAuthException("Unexpected issued_token_type in JAG response: " + + tokenResponse.getIssuedTokenType() + " (expected " + TOKEN_TYPE_ID_JAG + ")")); + } + if (!"N_A".equalsIgnoreCase(tokenResponse.getTokenType())) { + return Mono.error(new EnterpriseAuthException("Unexpected token_type in JAG response: " + + tokenResponse.getTokenType() + " (expected N_A per RFC 8693 §2.2.1)")); + } + if (tokenResponse.getAccessToken() == null || tokenResponse.getAccessToken().isBlank()) { + return Mono + .error(new EnterpriseAuthException("JAG token exchange response is missing access_token")); + } + logger.debug("JAG token exchange successful"); + return Mono.just(tokenResponse.getAccessToken()); + } + catch (EnterpriseAuthException e) { + return Mono.error(e); + } + catch (Exception e) { + return Mono.error(new EnterpriseAuthException("Failed to parse JAG token exchange response", e)); + } + }); + } + + /** + * Discovers the enterprise IdP's token endpoint via RFC 8414, then requests a JAG via + * RFC 8693 token exchange. + *

+ * If {@link DiscoverAndRequestJwtAuthGrantOptions#getIdpTokenEndpoint()} 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 a {@link Mono} emitting the JAG string, or an error of type + * {@link EnterpriseAuthException} + */ + public static Mono discoverAndRequestJwtAuthorizationGrant(DiscoverAndRequestJwtAuthGrantOptions options, + HttpClient httpClient) { + Mono tokenEndpointMono; + if (options.getIdpTokenEndpoint() != null) { + tokenEndpointMono = Mono.just(options.getIdpTokenEndpoint()); + } + else { + tokenEndpointMono = discoverAuthServerMetadata(options.getIdpUrl(), httpClient).flatMap(metadata -> { + if (metadata.getTokenEndpoint() == null) { + return Mono.error(new EnterpriseAuthException("No token_endpoint in IdP metadata at " + + options.getIdpUrl() + ". Ensure the IdP supports RFC 8414.")); + } + return Mono.just(metadata.getTokenEndpoint()); + }); + } + + return tokenEndpointMono.flatMap(tokenEndpoint -> { + RequestJwtAuthGrantOptions grantOptions = RequestJwtAuthGrantOptions.builder() + .tokenEndpoint(tokenEndpoint) + .idToken(options.getIdToken()) + .clientId(options.getClientId()) + .clientSecret(options.getClientSecret()) + .audience(options.getAudience()) + .resource(options.getResource()) + .scope(options.getScope()) + .build(); + return requestJwtAuthorizationGrant(grantOptions, 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 {@link JwtBearerAccessTokenResponse} includes the access token and, if + * the server provided an {@code expires_in} value, an absolute + * {@link JwtBearerAccessTokenResponse#getExpiresAt() expiresAt} timestamp computed + * from the current system time. + * @param options request parameters including the MCP auth server token endpoint, JAG + * assertion, and client credentials + * @param httpClient the HTTP client to use + * @return a {@link Mono} emitting the {@link JwtBearerAccessTokenResponse}, or an + * error of type {@link EnterpriseAuthException} + */ + public static Mono exchangeJwtBearerGrant(ExchangeJwtBearerGrantOptions options, + HttpClient httpClient) { + return Mono.defer(() -> { + List params = new ArrayList<>(); + params.add(encode("grant_type") + "=" + encode(GRANT_TYPE_JWT_BEARER)); + params.add(encode("assertion") + "=" + encode(options.getAssertion())); + params.add(encode("client_id") + "=" + encode(options.getClientId())); + if (options.getClientSecret() != null) { + params.add(encode("client_secret") + "=" + encode(options.getClientSecret())); + } + if (options.getScope() != null) { + params.add(encode("scope") + "=" + encode(options.getScope())); + } + String body = String.join("&", params); + logger.debug("Exchanging JWT bearer grant at {}", options.getTokenEndpoint()); + HttpRequest request = HttpRequest.newBuilder(URI.create(options.getTokenEndpoint())) + .POST(HttpRequest.BodyPublishers.ofString(body)) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Accept", "application/json") + .build(); + return Mono.fromFuture(() -> httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())); + }).flatMap(response -> { + if (response.statusCode() != 200) { + return Mono.error(new EnterpriseAuthException( + "JWT bearer grant exchange failed: HTTP " + response.statusCode() + " - " + response.body())); + } + try { + McpJsonMapper mapper = McpJsonDefaults.getMapper(); + JwtBearerAccessTokenResponse tokenResponse = mapper.readValue(response.body(), + JwtBearerAccessTokenResponse.class); + + if (tokenResponse.getAccessToken() == null || tokenResponse.getAccessToken().isBlank()) { + return Mono.error( + new EnterpriseAuthException("JWT bearer grant exchange response is missing access_token")); + } + // Compute absolute expiry from relative expires_in + if (tokenResponse.getExpiresIn() != null) { + tokenResponse.setExpiresAt(Instant.now().plusSeconds(tokenResponse.getExpiresIn())); + } + logger.debug("JWT bearer grant exchange successful; expires_in={}", tokenResponse.getExpiresIn()); + return Mono.just(tokenResponse); + } + catch (EnterpriseAuthException e) { + return Mono.error(e); + } + catch (Exception e) { + return Mono.error(new EnterpriseAuthException("Failed to parse JWT bearer grant exchange response", e)); + } + }); + } + + // ----------------------------------------------------------------------- + // Internal helpers + // ----------------------------------------------------------------------- + + private static String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthAssertionContext.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthAssertionContext.java new file mode 100644 index 000000000..6726f6d00 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthAssertionContext.java @@ -0,0 +1,52 @@ +/* + * Copyright 2024-2025 the original author or authors. + */ + +package io.modelcontextprotocol.client.auth; + +import java.net.URI; +import java.util.Objects; + +/** + * Context passed to the assertion callback in {@link EnterpriseAuthProvider}. + *

+ * Contains the resource URL of the MCP server and the URL of the authorization server + * that was discovered for that resource. The callback uses this context to obtain a + * suitable assertion (e.g., an OIDC ID token) from the enterprise IdP. + * + * @author MCP SDK Contributors + */ +public class EnterpriseAuthAssertionContext { + + private final URI resourceUrl; + + private final URI authorizationServerUrl; + + /** + * Creates a new {@link EnterpriseAuthAssertionContext}. + * @param resourceUrl the URL of the MCP resource being accessed (must not be + * {@code null}) + * @param authorizationServerUrl the URL of the MCP authorization server discovered + * for the resource (must not be {@code null}) + */ + public EnterpriseAuthAssertionContext(URI resourceUrl, URI authorizationServerUrl) { + this.resourceUrl = Objects.requireNonNull(resourceUrl, "resourceUrl must not be null"); + this.authorizationServerUrl = Objects.requireNonNull(authorizationServerUrl, + "authorizationServerUrl must not be null"); + } + + /** + * Returns the URL of the MCP resource being accessed. + */ + public URI getResourceUrl() { + return resourceUrl; + } + + /** + * Returns the URL of the MCP authorization server for the resource. + */ + public URI getAuthorizationServerUrl() { + return authorizationServerUrl; + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthException.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthException.java new file mode 100644 index 000000000..26d4e87dd --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthException.java @@ -0,0 +1,32 @@ +/* + * Copyright 2024-2025 the original author or authors. + */ + +package io.modelcontextprotocol.client.auth; + +/** + * Exception thrown when an error occurs during the Enterprise Managed Authorization + * (SEP-990) flow. + * + * @author MCP SDK Contributors + */ +public class EnterpriseAuthException extends RuntimeException { + + /** + * Creates a new {@code EnterpriseAuthException} with the given message. + * @param message the error message + */ + public EnterpriseAuthException(String message) { + super(message); + } + + /** + * Creates a new {@code EnterpriseAuthException} with the given message and cause. + * @param message the error message + * @param cause the underlying cause + */ + public EnterpriseAuthException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthProvider.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthProvider.java new file mode 100644 index 000000000..ecba04694 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthProvider.java @@ -0,0 +1,205 @@ +/* + * Copyright 2024-2025 the original author or authors. + */ + +package io.modelcontextprotocol.client.auth; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; + +import io.modelcontextprotocol.client.transport.customizer.McpAsyncHttpClientRequestCustomizer; +import io.modelcontextprotocol.common.McpTransportContext; +import org.reactivestreams.Publisher; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +/** + * Layer 3 implementation of Enterprise Managed Authorization (SEP-990). + *

+ * Implements {@link McpAsyncHttpClientRequestCustomizer} so that it can be registered + * directly with any HTTP transport. On each request it: + *

    + *
  1. Checks an in-memory access token cache.
  2. + *
  3. If the cache is empty or the token is expired (within a 30-second buffer), it + * performs the full enterprise auth flow: + *
      + *
    1. Discovers the MCP authorization server metadata via RFC 8414.
    2. + *
    3. Invokes the {@link EnterpriseAuthProviderOptions#getAssertionCallback() assertion + * callback} to obtain a JWT Authorization Grant (ID-JAG) from the enterprise IdP.
    4. + *
    5. Exchanges the JAG for an OAuth 2.0 access token via RFC 7523 at the MCP + * authorization server's token endpoint.
    6. + *
    7. Caches the access token.
    8. + *
    + *
  4. + *
  5. Adds an {@code Authorization: Bearer {token}} header to the outgoing request.
  6. + *
+ * + *

Usage

+ * + *
{@code
+ * EnterpriseAuthProvider provider = new EnterpriseAuthProvider(
+ *     EnterpriseAuthProviderOptions.builder()
+ *         .clientId("my-client-id")
+ *         .clientSecret("my-client-secret")
+ *         .assertionCallback(ctx -> {
+ *             // Step 1: exchange your enterprise ID token for a JAG
+ *             return EnterpriseAuth.discoverAndRequestJwtAuthorizationGrant(
+ *                 DiscoverAndRequestJwtAuthGrantOptions.builder()
+ *                     .idpUrl(ctx.getAuthorizationServerUrl().toString())
+ *                     .idToken(myIdTokenSupplier.get())
+ *                     .clientId("idp-client-id")
+ *                     .build(),
+ *                 httpClient);
+ *         })
+ *         .build());
+ *
+ * // Register with an HTTP transport
+ * HttpClientStreamableHttpTransport transport = HttpClientStreamableHttpTransport.builder(serverUrl)
+ *     .httpRequestCustomizer(provider)
+ *     .build();
+ * }
+ * + * @author MCP SDK Contributors + * @see EnterpriseAuth + * @see EnterpriseAuthProviderOptions + */ +public class EnterpriseAuthProvider implements McpAsyncHttpClientRequestCustomizer { + + private static final Logger logger = LoggerFactory.getLogger(EnterpriseAuthProvider.class); + + /** + * Proactive refresh buffer: treat a token as expired this many seconds before its + * actual expiry to avoid using a token that expires mid-flight. + */ + private static final Duration EXPIRY_BUFFER = Duration.ofSeconds(30); + + private final EnterpriseAuthProviderOptions options; + + private final HttpClient httpClient; + + private final AtomicReference cachedTokenRef = new AtomicReference<>(); + + /** + * Creates a new {@link EnterpriseAuthProvider} using the default {@link HttpClient}. + * @param options provider options including client credentials and the assertion + * callback (must not be {@code null}) + */ + public EnterpriseAuthProvider(EnterpriseAuthProviderOptions options) { + this(options, HttpClient.newHttpClient()); + } + + /** + * Creates a new {@link EnterpriseAuthProvider} with a custom {@link HttpClient}. + *

+ * Use this constructor when you need to configure TLS, proxies, or other HTTP client + * settings. + * @param options provider options (must not be {@code null}) + * @param httpClient the HTTP client to use for token discovery and exchange requests + * (must not be {@code null}) + */ + public EnterpriseAuthProvider(EnterpriseAuthProviderOptions options, HttpClient httpClient) { + this.options = Objects.requireNonNull(options, "options must not be null"); + this.httpClient = Objects.requireNonNull(httpClient, "httpClient must not be null"); + } + + /** + * Injects an {@code Authorization: Bearer} header into the outgoing HTTP request, + * obtaining or refreshing the access token as needed. + */ + @Override + public Publisher customize(HttpRequest.Builder builder, String method, URI endpoint, + String body, McpTransportContext context) { + return getAccessToken(endpoint).map(token -> builder.header("Authorization", "Bearer " + token)); + } + + /** + * Invalidates the cached access token, forcing the next request to perform a full + * enterprise auth flow. + *

+ * Useful after receiving a {@code 401 Unauthorized} response from the MCP server. + */ + public void invalidateCache() { + logger.debug("Invalidating cached enterprise auth token"); + cachedTokenRef.set(null); + } + + // ----------------------------------------------------------------------- + // Private helpers + // ----------------------------------------------------------------------- + + private Mono getAccessToken(URI endpoint) { + JwtBearerAccessTokenResponse cached = cachedTokenRef.get(); + if (cached != null && !isExpiredOrNearlyExpired(cached)) { + logger.debug("Using cached enterprise auth token"); + return Mono.just(cached.getAccessToken()); + } + logger.debug("Cached enterprise auth token is absent or expired; fetching new token"); + return fetchNewToken(endpoint).doOnNext(response -> { + cachedTokenRef.set(response); + logger.debug("Cached new enterprise auth token; expires_in={}", + response.getExpiresIn() != null ? response.getExpiresIn() + "s" : "unknown"); + }).map(JwtBearerAccessTokenResponse::getAccessToken); + } + + private boolean isExpiredOrNearlyExpired(JwtBearerAccessTokenResponse token) { + Instant expiresAt = token.getExpiresAt(); + if (expiresAt == null) { + return false; + } + return Instant.now().isAfter(expiresAt.minus(EXPIRY_BUFFER)); + } + + private Mono fetchNewToken(URI endpoint) { + URI resourceBaseUri = deriveBaseUri(endpoint); + logger.debug("Discovering MCP authorization server for resource {}", resourceBaseUri); + + return EnterpriseAuth.discoverAuthServerMetadata(resourceBaseUri.toString(), httpClient).flatMap(metadata -> { + if (metadata.getTokenEndpoint() == null) { + return Mono.error(new EnterpriseAuthException("No token_endpoint in authorization server metadata for " + + resourceBaseUri + ". Ensure the MCP server supports RFC 8414.")); + } + + // Resolve the authorization server URL: prefer issuer, fall back to base URI + URI authServerUri; + if (metadata.getIssuer() != null && !metadata.getIssuer().isBlank()) { + authServerUri = URI.create(metadata.getIssuer()); + } + else { + authServerUri = resourceBaseUri; + } + + EnterpriseAuthAssertionContext assertionContext = new EnterpriseAuthAssertionContext(resourceBaseUri, + authServerUri); + logger.debug("Invoking assertion callback for resourceUrl={}, authServerUrl={}", resourceBaseUri, + authServerUri); + + return options.getAssertionCallback().apply(assertionContext).flatMap(assertion -> { + ExchangeJwtBearerGrantOptions exchangeOptions = ExchangeJwtBearerGrantOptions.builder() + .tokenEndpoint(metadata.getTokenEndpoint()) + .assertion(assertion) + .clientId(options.getClientId()) + .clientSecret(options.getClientSecret()) + .scope(options.getScope()) + .build(); + return EnterpriseAuth.exchangeJwtBearerGrant(exchangeOptions, httpClient); + }); + }); + } + + /** + * Extracts the scheme+host+port from the given URI, dropping any path, query, or + * fragment. This is the URL against which RFC 8414 discovery is performed. + */ + private static URI deriveBaseUri(URI uri) { + int port = uri.getPort(); + String base = uri.getScheme() + "://" + uri.getHost() + (port != -1 ? ":" + port : ""); + return URI.create(base); + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthProviderOptions.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthProviderOptions.java new file mode 100644 index 000000000..2f696f1c0 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthProviderOptions.java @@ -0,0 +1,115 @@ +/* + * Copyright 2024-2025 the original author or authors. + */ + +package io.modelcontextprotocol.client.auth; + +import java.util.Objects; +import java.util.function.Function; + +import reactor.core.publisher.Mono; + +/** + * Configuration options for {@link EnterpriseAuthProvider}. + *

+ * At minimum, {@link #clientId} and {@link #assertionCallback} are required. + * + * @author MCP SDK Contributors + */ +public class EnterpriseAuthProviderOptions { + + /** + * The OAuth 2.0 client ID registered at the MCP authorization server. Required. + */ + private final String clientId; + + /** + * The OAuth 2.0 client secret. Optional for public clients. + */ + private final String clientSecret; + + /** + * The {@code scope} parameter to request when exchanging the JWT bearer grant. + * Optional. + */ + private final String scope; + + /** + * Callback that obtains an assertion (ID token / JAG) for the given context. + *

+ * The callback receives an {@link EnterpriseAuthAssertionContext} describing the MCP + * resource and its authorization server, and must return a {@link Mono} that emits + * the assertion string (e.g., an OIDC ID token from the enterprise IdP). + *

+ * Required. + */ + private final Function> assertionCallback; + + private EnterpriseAuthProviderOptions(Builder builder) { + this.clientId = Objects.requireNonNull(builder.clientId, "clientId must not be null"); + this.clientSecret = builder.clientSecret; + this.scope = builder.scope; + this.assertionCallback = Objects.requireNonNull(builder.assertionCallback, + "assertionCallback must not be null"); + } + + public String getClientId() { + return clientId; + } + + public String getClientSecret() { + return clientSecret; + } + + public String getScope() { + return scope; + } + + public Function> getAssertionCallback() { + return assertionCallback; + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + + private String clientId; + + private String clientSecret; + + private String scope; + + private Function> assertionCallback; + + private Builder() { + } + + public Builder clientId(String clientId) { + this.clientId = clientId; + return this; + } + + public Builder clientSecret(String clientSecret) { + this.clientSecret = clientSecret; + return this; + } + + public Builder scope(String scope) { + this.scope = scope; + return this; + } + + public Builder assertionCallback(Function> assertionCallback) { + this.assertionCallback = assertionCallback; + return this; + } + + public EnterpriseAuthProviderOptions build() { + return new EnterpriseAuthProviderOptions(this); + } + + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/ExchangeJwtBearerGrantOptions.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/ExchangeJwtBearerGrantOptions.java new file mode 100644 index 000000000..0d7e8bfa3 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/ExchangeJwtBearerGrantOptions.java @@ -0,0 +1,115 @@ +/* + * Copyright 2024-2025 the original author or authors. + */ + +package io.modelcontextprotocol.client.auth; + +import java.util.Objects; + +/** + * Options for {@link 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. + * + * @author MCP SDK Contributors + * @see RFC 7523 + */ +public class ExchangeJwtBearerGrantOptions { + + /** The full URL of the MCP authorization server's token endpoint. */ + private final String tokenEndpoint; + + /** The JWT Authorization Grant (ID-JAG) obtained from step 1. */ + private final String assertion; + + /** The OAuth 2.0 client ID registered at the MCP authorization server. */ + private final String clientId; + + /** The OAuth 2.0 client secret (may be {@code null} for public clients). */ + private final String clientSecret; + + /** The {@code scope} parameter for the token request (optional). */ + private final String scope; + + private ExchangeJwtBearerGrantOptions(Builder builder) { + this.tokenEndpoint = Objects.requireNonNull(builder.tokenEndpoint, "tokenEndpoint must not be null"); + this.assertion = Objects.requireNonNull(builder.assertion, "assertion must not be null"); + this.clientId = Objects.requireNonNull(builder.clientId, "clientId must not be null"); + this.clientSecret = builder.clientSecret; + this.scope = builder.scope; + } + + public String getTokenEndpoint() { + return tokenEndpoint; + } + + public String getAssertion() { + return assertion; + } + + public String getClientId() { + return clientId; + } + + public String getClientSecret() { + return clientSecret; + } + + public String getScope() { + return scope; + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + + private String tokenEndpoint; + + private String assertion; + + private String clientId; + + private String clientSecret; + + private String scope; + + private Builder() { + } + + public Builder tokenEndpoint(String tokenEndpoint) { + this.tokenEndpoint = tokenEndpoint; + return this; + } + + public Builder assertion(String assertion) { + this.assertion = assertion; + return this; + } + + public Builder clientId(String clientId) { + this.clientId = clientId; + return this; + } + + public Builder clientSecret(String clientSecret) { + this.clientSecret = clientSecret; + return this; + } + + public Builder scope(String scope) { + this.scope = scope; + return this; + } + + public ExchangeJwtBearerGrantOptions build() { + return new ExchangeJwtBearerGrantOptions(this); + } + + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/JagTokenExchangeResponse.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/JagTokenExchangeResponse.java new file mode 100644 index 000000000..35f1d6532 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/JagTokenExchangeResponse.java @@ -0,0 +1,87 @@ +/* + * Copyright 2024-2025 the original author or authors. + */ + +package io.modelcontextprotocol.client.auth; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * 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 three key fields are: + *

+ * + * @author MCP SDK Contributors + * @see RFC 8693 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class JagTokenExchangeResponse { + + @JsonProperty("access_token") + private String accessToken; + + @JsonProperty("issued_token_type") + private String issuedTokenType; + + @JsonProperty("token_type") + private String tokenType; + + @JsonProperty("scope") + private String scope; + + @JsonProperty("expires_in") + private Integer expiresIn; + + public String getAccessToken() { + return accessToken; + } + + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } + + public String getIssuedTokenType() { + return issuedTokenType; + } + + public void setIssuedTokenType(String issuedTokenType) { + this.issuedTokenType = issuedTokenType; + } + + public String getTokenType() { + return tokenType; + } + + public void setTokenType(String tokenType) { + this.tokenType = tokenType; + } + + public String getScope() { + return scope; + } + + public void setScope(String scope) { + this.scope = scope; + } + + public Integer getExpiresIn() { + return expiresIn; + } + + public void setExpiresIn(Integer expiresIn) { + this.expiresIn = expiresIn; + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/JwtBearerAccessTokenResponse.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/JwtBearerAccessTokenResponse.java new file mode 100644 index 000000000..bba6daa0b --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/JwtBearerAccessTokenResponse.java @@ -0,0 +1,106 @@ +/* + * Copyright 2024-2025 the original author or authors. + */ + +package io.modelcontextprotocol.client.auth; + +import java.time.Instant; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * 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 Server's + * authorization server. + * + * @author MCP SDK Contributors + * @see RFC 7523 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class JwtBearerAccessTokenResponse { + + @JsonProperty("access_token") + private String accessToken; + + @JsonProperty("token_type") + private String tokenType; + + @JsonProperty("expires_in") + private Integer expiresIn; + + @JsonProperty("scope") + private String scope; + + @JsonProperty("refresh_token") + private String refreshToken; + + /** + * The absolute time at which this token expires. Computed from {@code expires_in} + * upon deserialization by {@link EnterpriseAuth}. Marked {@code transient} so that + * JSON mappers skip this field during deserialization. + */ + private transient Instant expiresAt; + + public String getAccessToken() { + return accessToken; + } + + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } + + public String getTokenType() { + return tokenType; + } + + public void setTokenType(String tokenType) { + this.tokenType = tokenType; + } + + public Integer getExpiresIn() { + return expiresIn; + } + + public void setExpiresIn(Integer expiresIn) { + this.expiresIn = expiresIn; + } + + public String getScope() { + return scope; + } + + public void setScope(String scope) { + this.scope = scope; + } + + public String getRefreshToken() { + return refreshToken; + } + + public void setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + + public Instant getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Instant expiresAt) { + this.expiresAt = expiresAt; + } + + /** + * Returns {@code true} if this token has expired (or has no expiry information). + */ + public boolean isExpired() { + if (expiresAt == null) { + return false; + } + return Instant.now().isAfter(expiresAt); + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/RequestJwtAuthGrantOptions.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/RequestJwtAuthGrantOptions.java new file mode 100644 index 000000000..913c4fdd5 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/RequestJwtAuthGrantOptions.java @@ -0,0 +1,143 @@ +/* + * Copyright 2024-2025 the original author or authors. + */ + +package io.modelcontextprotocol.client.auth; + +import java.util.Objects; + +/** + * Options for {@link 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). + * + * @author MCP SDK Contributors + */ +public class RequestJwtAuthGrantOptions { + + /** The full URL of the enterprise IdP's token endpoint. */ + private final String tokenEndpoint; + + /** The ID token (assertion) issued by the enterprise IdP. */ + private final String idToken; + + /** The OAuth 2.0 client ID registered at the enterprise IdP. */ + private final String clientId; + + /** The OAuth 2.0 client secret (may be {@code null} for public clients). */ + private final String clientSecret; + + /** The {@code audience} parameter for the token exchange request (optional). */ + private final String audience; + + /** The {@code resource} parameter for the token exchange request (optional). */ + private final String resource; + + /** The {@code scope} parameter for the token exchange request (optional). */ + private final String scope; + + private RequestJwtAuthGrantOptions(Builder builder) { + this.tokenEndpoint = Objects.requireNonNull(builder.tokenEndpoint, "tokenEndpoint must not be null"); + this.idToken = Objects.requireNonNull(builder.idToken, "idToken must not be null"); + this.clientId = Objects.requireNonNull(builder.clientId, "clientId must not be null"); + this.clientSecret = builder.clientSecret; + this.audience = builder.audience; + this.resource = builder.resource; + this.scope = builder.scope; + } + + public String getTokenEndpoint() { + return tokenEndpoint; + } + + public String getIdToken() { + return idToken; + } + + public String getClientId() { + return clientId; + } + + public String getClientSecret() { + return clientSecret; + } + + public String getAudience() { + return audience; + } + + public String getResource() { + return resource; + } + + public String getScope() { + return scope; + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + + private String tokenEndpoint; + + private String idToken; + + private String clientId; + + private String clientSecret; + + private String audience; + + private String resource; + + private String scope; + + private Builder() { + } + + public Builder tokenEndpoint(String tokenEndpoint) { + this.tokenEndpoint = tokenEndpoint; + return this; + } + + public Builder idToken(String idToken) { + this.idToken = idToken; + return this; + } + + public Builder clientId(String clientId) { + this.clientId = clientId; + return this; + } + + public Builder clientSecret(String clientSecret) { + this.clientSecret = clientSecret; + return this; + } + + public Builder audience(String audience) { + this.audience = audience; + return this; + } + + public Builder resource(String resource) { + this.resource = resource; + return this; + } + + public Builder scope(String scope) { + this.scope = scope; + return this; + } + + public RequestJwtAuthGrantOptions build() { + return new RequestJwtAuthGrantOptions(this); + } + + } + +} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/auth/EnterpriseAuthTest.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/auth/EnterpriseAuthTest.java new file mode 100644 index 000000000..f7be577cd --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/auth/EnterpriseAuthTest.java @@ -0,0 +1,557 @@ +/* + * Copyright 2024-2025 the original author or authors. + */ + +package io.modelcontextprotocol.client.auth; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import io.modelcontextprotocol.common.McpTransportContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Tests for {@link EnterpriseAuth} and {@link EnterpriseAuthProvider}. + * + * @author MCP SDK Contributors + */ +class EnterpriseAuthTest { + + private HttpServer server; + + private String baseUrl; + + private HttpClient httpClient; + + @BeforeEach + void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress(0), 0); + server.start(); + int port = server.getAddress().getPort(); + baseUrl = "http://localhost:" + port; + httpClient = HttpClient.newHttpClient(); + } + + @AfterEach + void stopServer() { + if (server != null) { + server.stop(0); + } + } + + // ----------------------------------------------------------------------- + // discoverAuthServerMetadata — success paths + // ----------------------------------------------------------------------- + + @Test + void discoverAuthServerMetadata_oauthWellKnown_success() { + server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, """ + { + "issuer": "https://auth.example.com", + "token_endpoint": "https://auth.example.com/token", + "authorization_endpoint": "https://auth.example.com/authorize" + }""")); + + StepVerifier.create(EnterpriseAuth.discoverAuthServerMetadata(baseUrl, httpClient)).assertNext(metadata -> { + assertThat(metadata.getIssuer()).isEqualTo("https://auth.example.com"); + assertThat(metadata.getTokenEndpoint()).isEqualTo("https://auth.example.com/token"); + assertThat(metadata.getAuthorizationEndpoint()).isEqualTo("https://auth.example.com/authorize"); + }).verifyComplete(); + } + + @Test + void discoverAuthServerMetadata_fallsBackToOpenIdConfiguration() { + // Primary endpoint returns 404 + server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 404, "")); + // Fallback endpoint succeeds + server.createContext("/.well-known/openid-configuration", exchange -> sendJson(exchange, 200, """ + { + "issuer": "https://idp.example.com", + "token_endpoint": "https://idp.example.com/token" + }""")); + + StepVerifier.create(EnterpriseAuth.discoverAuthServerMetadata(baseUrl, httpClient)) + .assertNext(metadata -> assertThat(metadata.getTokenEndpoint()).isEqualTo("https://idp.example.com/token")) + .verifyComplete(); + } + + @Test + void discoverAuthServerMetadata_bothFail_emitsError() { + server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 500, "")); + server.createContext("/.well-known/openid-configuration", exchange -> sendJson(exchange, 500, "")); + + StepVerifier.create(EnterpriseAuth.discoverAuthServerMetadata(baseUrl, httpClient)) + .expectErrorMatches(e -> e instanceof EnterpriseAuthException && e.getMessage().contains("HTTP 500")) + .verify(); + } + + @Test + void discoverAuthServerMetadata_stripsTrailingSlash() { + server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, """ + {"issuer":"https://auth.example.com","token_endpoint":"https://auth.example.com/token"}""")); + + // Provide URL with trailing slash — should still work + StepVerifier.create(EnterpriseAuth.discoverAuthServerMetadata(baseUrl + "/", httpClient)) + .assertNext(metadata -> assertThat(metadata.getIssuer()).isEqualTo("https://auth.example.com")) + .verifyComplete(); + } + + // ----------------------------------------------------------------------- + // requestJwtAuthorizationGrant — success and validation + // ----------------------------------------------------------------------- + + @Test + void requestJwtAuthorizationGrant_success() { + server.createContext("/token", exchange -> { + String body = new String(exchange.getRequestBody().readAllBytes()); + assertThat(body).contains("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange"); + assertThat(body).contains("subject_token=my-id-token"); + assertThat(body).contains("subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aid_token"); + assertThat(body).contains("requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aid-jag"); + assertThat(body).contains("client_id=my-client"); + + sendJson(exchange, 200, """ + { + "access_token": "my-jag-token", + "issued_token_type": "urn:ietf:params:oauth:token-type:id-jag", + "token_type": "N_A" + }"""); + }); + + RequestJwtAuthGrantOptions options = RequestJwtAuthGrantOptions.builder() + .tokenEndpoint(baseUrl + "/token") + .idToken("my-id-token") + .clientId("my-client") + .build(); + + StepVerifier.create(EnterpriseAuth.requestJwtAuthorizationGrant(options, httpClient)) + .expectNext("my-jag-token") + .verifyComplete(); + } + + @Test + void requestJwtAuthorizationGrant_includesOptionalParams() { + server.createContext("/token", exchange -> { + String body = new String(exchange.getRequestBody().readAllBytes()); + assertThat(body).contains("client_secret=s3cr3t"); + assertThat(body).contains("audience=my-audience"); + assertThat(body).contains("resource=https%3A%2F%2Fmcp.example.com"); + assertThat(body).contains("scope=openid+profile"); + + sendJson(exchange, 200, """ + { + "access_token": "the-jag", + "issued_token_type": "urn:ietf:params:oauth:token-type:id-jag", + "token_type": "N_A" + }"""); + }); + + RequestJwtAuthGrantOptions options = RequestJwtAuthGrantOptions.builder() + .tokenEndpoint(baseUrl + "/token") + .idToken("tok") + .clientId("cid") + .clientSecret("s3cr3t") + .audience("my-audience") + .resource("https://mcp.example.com") + .scope("openid profile") + .build(); + + StepVerifier.create(EnterpriseAuth.requestJwtAuthorizationGrant(options, httpClient)) + .expectNext("the-jag") + .verifyComplete(); + } + + @Test + void requestJwtAuthorizationGrant_wrongIssuedTokenType_emitsError() { + server.createContext("/token", exchange -> sendJson(exchange, 200, """ + { + "access_token": "tok", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "token_type": "Bearer" + }""")); + + RequestJwtAuthGrantOptions options = RequestJwtAuthGrantOptions.builder() + .tokenEndpoint(baseUrl + "/token") + .idToken("id-tok") + .clientId("cid") + .build(); + + StepVerifier.create(EnterpriseAuth.requestJwtAuthorizationGrant(options, httpClient)) + .expectErrorMatches( + e -> e instanceof EnterpriseAuthException && e.getMessage().contains("issued_token_type")) + .verify(); + } + + @Test + void requestJwtAuthorizationGrant_wrongTokenType_emitsError() { + server.createContext("/token", exchange -> sendJson(exchange, 200, """ + { + "access_token": "tok", + "issued_token_type": "urn:ietf:params:oauth:token-type:id-jag", + "token_type": "Bearer" + }""")); + + RequestJwtAuthGrantOptions options = RequestJwtAuthGrantOptions.builder() + .tokenEndpoint(baseUrl + "/token") + .idToken("id-tok") + .clientId("cid") + .build(); + + StepVerifier.create(EnterpriseAuth.requestJwtAuthorizationGrant(options, httpClient)) + .expectErrorMatches(e -> e instanceof EnterpriseAuthException && e.getMessage().contains("token_type")) + .verify(); + } + + @Test + void requestJwtAuthorizationGrant_httpError_emitsError() { + server.createContext("/token", exchange -> sendJson(exchange, 400, "{\"error\":\"invalid_client\"}")); + + RequestJwtAuthGrantOptions options = RequestJwtAuthGrantOptions.builder() + .tokenEndpoint(baseUrl + "/token") + .idToken("id-tok") + .clientId("cid") + .build(); + + StepVerifier.create(EnterpriseAuth.requestJwtAuthorizationGrant(options, httpClient)) + .expectErrorMatches(e -> e instanceof EnterpriseAuthException && e.getMessage().contains("HTTP 400")) + .verify(); + } + + // ----------------------------------------------------------------------- + // discoverAndRequestJwtAuthorizationGrant + // ----------------------------------------------------------------------- + + @Test + void discoverAndRequestJwtAuthorizationGrant_discoversAndExchanges() { + server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, + "{\"issuer\":\"" + baseUrl + "\",\"token_endpoint\":\"" + baseUrl + "/token\"}")); + server.createContext("/token", exchange -> sendJson(exchange, 200, """ + { + "access_token": "discovered-jag", + "issued_token_type": "urn:ietf:params:oauth:token-type:id-jag", + "token_type": "N_A" + }""")); + + DiscoverAndRequestJwtAuthGrantOptions options = DiscoverAndRequestJwtAuthGrantOptions.builder() + .idpUrl(baseUrl) + .idToken("my-id-tok") + .clientId("cid") + .build(); + + StepVerifier.create(EnterpriseAuth.discoverAndRequestJwtAuthorizationGrant(options, httpClient)) + .expectNext("discovered-jag") + .verifyComplete(); + } + + @Test + void discoverAndRequestJwtAuthorizationGrant_overriddenTokenEndpoint_skipsDiscovery() { + // No well-known handler registered — if discovery were attempted, connection + // would fail + server.createContext("/direct-token", exchange -> sendJson(exchange, 200, """ + { + "access_token": "direct-jag", + "issued_token_type": "urn:ietf:params:oauth:token-type:id-jag", + "token_type": "N_A" + }""")); + + DiscoverAndRequestJwtAuthGrantOptions options = DiscoverAndRequestJwtAuthGrantOptions.builder() + .idpUrl(baseUrl) + .idpTokenEndpoint(baseUrl + "/direct-token") + .idToken("my-id-tok") + .clientId("cid") + .build(); + + StepVerifier.create(EnterpriseAuth.discoverAndRequestJwtAuthorizationGrant(options, httpClient)) + .expectNext("direct-jag") + .verifyComplete(); + } + + // ----------------------------------------------------------------------- + // exchangeJwtBearerGrant + // ----------------------------------------------------------------------- + + @Test + void exchangeJwtBearerGrant_success() { + server.createContext("/token", exchange -> { + String body = new String(exchange.getRequestBody().readAllBytes()); + assertThat(body).contains("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer"); + assertThat(body).contains("assertion=my-jag"); + assertThat(body).contains("client_id=cid"); + + sendJson(exchange, 200, """ + { + "access_token": "the-access-token", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "mcp" + }"""); + }); + + ExchangeJwtBearerGrantOptions options = ExchangeJwtBearerGrantOptions.builder() + .tokenEndpoint(baseUrl + "/token") + .assertion("my-jag") + .clientId("cid") + .build(); + + StepVerifier.create(EnterpriseAuth.exchangeJwtBearerGrant(options, httpClient)).assertNext(response -> { + assertThat(response.getAccessToken()).isEqualTo("the-access-token"); + assertThat(response.getTokenType()).isEqualTo("Bearer"); + assertThat(response.getExpiresIn()).isEqualTo(3600); + assertThat(response.getScope()).isEqualTo("mcp"); + assertThat(response.getExpiresAt()).isNotNull(); + assertThat(response.isExpired()).isFalse(); + }).verifyComplete(); + } + + @Test + void exchangeJwtBearerGrant_missingAccessToken_emitsError() { + server.createContext("/token", exchange -> sendJson(exchange, 200, """ + {"token_type": "Bearer"}""")); + + ExchangeJwtBearerGrantOptions options = ExchangeJwtBearerGrantOptions.builder() + .tokenEndpoint(baseUrl + "/token") + .assertion("jag") + .clientId("cid") + .build(); + + StepVerifier.create(EnterpriseAuth.exchangeJwtBearerGrant(options, httpClient)) + .expectErrorMatches(e -> e instanceof EnterpriseAuthException && e.getMessage().contains("access_token")) + .verify(); + } + + @Test + void exchangeJwtBearerGrant_httpError_emitsError() { + server.createContext("/token", exchange -> sendJson(exchange, 401, "{\"error\":\"invalid_client\"}")); + + ExchangeJwtBearerGrantOptions options = ExchangeJwtBearerGrantOptions.builder() + .tokenEndpoint(baseUrl + "/token") + .assertion("jag") + .clientId("cid") + .build(); + + StepVerifier.create(EnterpriseAuth.exchangeJwtBearerGrant(options, httpClient)) + .expectErrorMatches(e -> e instanceof EnterpriseAuthException && e.getMessage().contains("HTTP 401")) + .verify(); + } + + // ----------------------------------------------------------------------- + // EnterpriseAuthProvider + // ----------------------------------------------------------------------- + + @Test + void enterpriseAuthProvider_injectsAuthorizationHeader() { + // Auth server discovery + server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, + "{\"issuer\":\"" + baseUrl + "\",\"token_endpoint\":\"" + baseUrl + "/mcp-token\"}")); + // JWT bearer grant exchange + server.createContext("/mcp-token", exchange -> sendJson(exchange, 200, """ + { + "access_token": "final-access-token", + "token_type": "Bearer", + "expires_in": 3600 + }""")); + + // The assertion callback simulates having already obtained a JAG from the IdP + EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() + .clientId("client-id") + .assertionCallback(ctx -> Mono.just("pre-obtained-jag")) + .build(); + + EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); + + URI endpoint = URI.create(baseUrl + "/mcp"); + HttpRequest.Builder builder = HttpRequest.newBuilder(endpoint); + + StepVerifier + .create(Mono.from(provider.customize(builder, "POST", endpoint, "{}", McpTransportContext.EMPTY)) + .map(HttpRequest.Builder::build) + .map(req -> req.headers().firstValue("Authorization").orElse(null))) + .expectNext("Bearer final-access-token") + .verifyComplete(); + } + + @Test + void enterpriseAuthProvider_cachesPreviousToken() { + int[] callCount = { 0 }; + + server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, + "{\"issuer\":\"" + baseUrl + "\",\"token_endpoint\":\"" + baseUrl + "/mcp-token\"}")); + server.createContext("/mcp-token", exchange -> { + callCount[0]++; + sendJson(exchange, 200, """ + { + "access_token": "cached-token", + "token_type": "Bearer", + "expires_in": 3600 + }"""); + }); + + EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() + .clientId("client-id") + .assertionCallback(ctx -> Mono.just("jag")) + .build(); + + EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); + + URI endpoint = URI.create(baseUrl + "/mcp"); + HttpRequest.Builder builder1 = HttpRequest.newBuilder(endpoint); + HttpRequest.Builder builder2 = HttpRequest.newBuilder(endpoint); + + // First request — fetches token + Mono.from(provider.customize(builder1, "POST", endpoint, null, McpTransportContext.EMPTY)).block(); + // Second request — should use cache + Mono.from(provider.customize(builder2, "POST", endpoint, null, McpTransportContext.EMPTY)).block(); + + assertThat(callCount[0]).isEqualTo(1); + } + + @Test + void enterpriseAuthProvider_invalidateCache_forcesRefetch() { + int[] callCount = { 0 }; + + server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, + "{\"issuer\":\"" + baseUrl + "\",\"token_endpoint\":\"" + baseUrl + "/mcp-token\"}")); + server.createContext("/mcp-token", exchange -> { + callCount[0]++; + sendJson(exchange, 200, """ + { + "access_token": "refreshed-token", + "token_type": "Bearer", + "expires_in": 3600 + }"""); + }); + + EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() + .clientId("client-id") + .assertionCallback(ctx -> Mono.just("jag")) + .build(); + + EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); + + URI endpoint = URI.create(baseUrl + "/mcp"); + + // First request + Mono.from( + provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) + .block(); + assertThat(callCount[0]).isEqualTo(1); + + // Invalidate + provider.invalidateCache(); + + // Second request — cache cleared, must fetch again + Mono.from( + provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) + .block(); + assertThat(callCount[0]).isEqualTo(2); + } + + @Test + void enterpriseAuthProvider_discoveryFails_emitsError() { + server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 500, "")); + server.createContext("/.well-known/openid-configuration", exchange -> sendJson(exchange, 500, "")); + + EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() + .clientId("cid") + .assertionCallback(ctx -> Mono.just("jag")) + .build(); + + EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); + URI endpoint = URI.create(baseUrl + "/mcp"); + + StepVerifier.create(Mono.from( + provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY))) + .expectErrorMatches(e -> e instanceof EnterpriseAuthException) + .verify(); + } + + @Test + void enterpriseAuthProvider_assertionCallbackError_emitsError() { + server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, + "{\"issuer\":\"" + baseUrl + "\",\"token_endpoint\":\"" + baseUrl + "/mcp-token\"}")); + + EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() + .clientId("cid") + .assertionCallback(ctx -> Mono.error(new RuntimeException("IdP unreachable"))) + .build(); + + EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); + URI endpoint = URI.create(baseUrl + "/mcp"); + + StepVerifier + .create(Mono.from(provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, + McpTransportContext.EMPTY))) + .expectErrorMatches(e -> e instanceof RuntimeException && e.getMessage().contains("IdP unreachable")) + .verify(); + } + + // ----------------------------------------------------------------------- + // EnterpriseAuthProviderOptions — validation + // ----------------------------------------------------------------------- + + @Test + void providerOptions_nullClientId_throws() { + assertThatThrownBy( + () -> EnterpriseAuthProviderOptions.builder().assertionCallback(ctx -> Mono.just("j")).build()) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("clientId"); + } + + @Test + void providerOptions_nullCallback_throws() { + assertThatThrownBy(() -> EnterpriseAuthProviderOptions.builder().clientId("cid").build()) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("assertionCallback"); + } + + // ----------------------------------------------------------------------- + // JwtBearerAccessTokenResponse helpers + // ----------------------------------------------------------------------- + + @Test + void jwtBearerAccessTokenResponse_isExpired_whenPastExpiresAt() { + JwtBearerAccessTokenResponse response = new JwtBearerAccessTokenResponse(); + response.setAccessToken("tok"); + response.setExpiresAt(java.time.Instant.now().minusSeconds(10)); + assertThat(response.isExpired()).isTrue(); + } + + @Test + void jwtBearerAccessTokenResponse_notExpired_whenNoExpiresAt() { + JwtBearerAccessTokenResponse response = new JwtBearerAccessTokenResponse(); + response.setAccessToken("tok"); + assertThat(response.isExpired()).isFalse(); + } + + // ----------------------------------------------------------------------- + // Helper + // ----------------------------------------------------------------------- + + private static void sendJson(HttpExchange exchange, int statusCode, String body) { + try { + byte[] bytes = body.getBytes(java.nio.charset.StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(statusCode, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(bytes); + } + } + catch (IOException e) { + throw new RuntimeException(e); + } + } + +} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/spec/json/gson/GsonMcpJsonMapperSupplier.java b/mcp-core/src/test/java/io/modelcontextprotocol/spec/json/gson/GsonMcpJsonMapperSupplier.java new file mode 100644 index 000000000..c0e1baedd --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/spec/json/gson/GsonMcpJsonMapperSupplier.java @@ -0,0 +1,51 @@ +package io.modelcontextprotocol.spec.json.gson; + +import java.lang.reflect.Field; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.gson.FieldNamingStrategy; +import com.google.gson.GsonBuilder; +import com.google.gson.ToNumberPolicy; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.McpJsonMapperSupplier; + +/** + * Test-only {@link McpJsonMapperSupplier} backed by Gson. Registered via + * {@code META-INF/services} so that {@code McpJsonDefaults.getMapper()} works in unit + * tests without requiring a Jackson module on the classpath. + *

+ * The Gson instance is configured with a {@link FieldNamingStrategy} that reads Jackson's + * {@link JsonProperty} annotation so that snake_case JSON fields (e.g. + * {@code token_endpoint}) map correctly to camelCase Java fields annotated with + * {@code @JsonProperty("token_endpoint")}. + */ +public class GsonMcpJsonMapperSupplier implements McpJsonMapperSupplier { + + @Override + public McpJsonMapper get() { + var gson = new GsonBuilder().serializeNulls() + .setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) + .setNumberToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) + .setFieldNamingStrategy(new JacksonPropertyFieldNamingStrategy()) + .create(); + return new GsonMcpJsonMapper(gson); + } + + /** + * Resolves a field name using the value of a {@link JsonProperty} annotation if + * present, otherwise falls back to the Java field name. + */ + private static final class JacksonPropertyFieldNamingStrategy implements FieldNamingStrategy { + + @Override + public String translateName(Field field) { + JsonProperty annotation = field.getAnnotation(JsonProperty.class); + if (annotation != null && annotation.value() != null && !annotation.value().isEmpty()) { + return annotation.value(); + } + return field.getName(); + } + + } + +} diff --git a/mcp-core/src/test/resources/META-INF/services/io.modelcontextprotocol.json.McpJsonMapperSupplier b/mcp-core/src/test/resources/META-INF/services/io.modelcontextprotocol.json.McpJsonMapperSupplier new file mode 100644 index 000000000..5bf822b4a --- /dev/null +++ b/mcp-core/src/test/resources/META-INF/services/io.modelcontextprotocol.json.McpJsonMapperSupplier @@ -0,0 +1 @@ +io.modelcontextprotocol.spec.json.gson.GsonMcpJsonMapperSupplier From 443e6e589f03ac1d6afca9e0e52f67e445e0a028 Mon Sep 17 00:00:00 2001 From: Prachi Pandey Date: Mon, 16 Mar 2026 11:58:36 +0530 Subject: [PATCH 2/4] Address PR review comments for enterprise managed authorization --- ...DiscoverAndRequestJwtAuthGrantOptions.java | 127 ++++++------------ .../client/auth/EnterpriseAuth.java | 96 ++++++++----- .../client/auth/EnterpriseAuthProvider.java | 16 ++- .../auth/ExchangeJwtBearerGrantOptions.java | 6 + .../client/auth/JagTokenExchangeResponse.java | 7 +- .../auth/RequestJwtAuthGrantOptions.java | 9 +- .../client/auth/EnterpriseAuthTest.java | 98 +++++++++++++- 7 files changed, 231 insertions(+), 128 deletions(-) diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/DiscoverAndRequestJwtAuthGrantOptions.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/DiscoverAndRequestJwtAuthGrantOptions.java index bb693dba4..22f5921b3 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/DiscoverAndRequestJwtAuthGrantOptions.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/DiscoverAndRequestJwtAuthGrantOptions.java @@ -7,15 +7,19 @@ import java.util.Objects; /** - * Options for {@link EnterpriseAuth#discoverAndRequestJwtAuthorizationGrant} — performs - * step 1 of the Enterprise Managed Authorization (SEP-990) flow by first discovering the - * IdP token endpoint via RFC 8414 metadata discovery, then requesting the JAG. + * Options for {@link EnterpriseAuth#discoverAndRequestJwtAuthorizationGrant} — extends + * {@link RequestJwtAuthGrantOptions} with IdP discovery support. *

- * If {@link #idpTokenEndpoint} is provided it is used directly and discovery is skipped. + * Performs step 1 of the Enterprise Managed Authorization (SEP-990) flow by first + * discovering the IdP token endpoint via RFC 8414 metadata discovery, then requesting the + * JAG. + *

+ * If {@link #getIdpTokenEndpoint()} is provided it is used directly and discovery is + * skipped. * * @author MCP SDK Contributors */ -public class DiscoverAndRequestJwtAuthGrantOptions { +public class DiscoverAndRequestJwtAuthGrantOptions extends RequestJwtAuthGrantOptions { /** * The base URL of the enterprise IdP. Used as the root URL for RFC 8414 discovery @@ -24,95 +28,33 @@ public class DiscoverAndRequestJwtAuthGrantOptions { */ private final String idpUrl; - /** - * Optional override for the IdP's token endpoint. When provided, RFC 8414 discovery - * is skipped. - */ - private final String idpTokenEndpoint; - - /** The ID token (assertion) issued by the enterprise IdP. */ - private final String idToken; - - /** The OAuth 2.0 client ID registered at the enterprise IdP. */ - private final String clientId; - - /** The OAuth 2.0 client secret (may be {@code null} for public clients). */ - private final String clientSecret; - - /** The {@code audience} parameter for the token exchange request (optional). */ - private final String audience; - - /** The {@code resource} parameter for the token exchange request (optional). */ - private final String resource; - - /** The {@code scope} parameter for the token exchange request (optional). */ - private final String scope; - private DiscoverAndRequestJwtAuthGrantOptions(Builder builder) { + super(builder); this.idpUrl = Objects.requireNonNull(builder.idpUrl, "idpUrl must not be null"); - this.idpTokenEndpoint = builder.idpTokenEndpoint; - this.idToken = Objects.requireNonNull(builder.idToken, "idToken must not be null"); - this.clientId = Objects.requireNonNull(builder.clientId, "clientId must not be null"); - this.clientSecret = builder.clientSecret; - this.audience = builder.audience; - this.resource = builder.resource; - this.scope = builder.scope; } public String getIdpUrl() { return idpUrl; } + /** + * Returns the optional pre-configured IdP token endpoint. When non-null, RFC 8414 + * discovery is skipped and this endpoint is used directly. + *

+ * This is a convenience method equivalent to {@link #getTokenEndpoint()}. + */ public String getIdpTokenEndpoint() { - return idpTokenEndpoint; - } - - public String getIdToken() { - return idToken; - } - - public String getClientId() { - return clientId; - } - - public String getClientSecret() { - return clientSecret; - } - - public String getAudience() { - return audience; - } - - public String getResource() { - return resource; - } - - public String getScope() { - return scope; + return getTokenEndpoint(); } public static Builder builder() { return new Builder(); } - public static final class Builder { + public static final class Builder extends RequestJwtAuthGrantOptions.Builder { private String idpUrl; - private String idpTokenEndpoint; - - private String idToken; - - private String clientId; - - private String clientSecret; - - private String audience; - - private String resource; - - private String scope; - private Builder() { } @@ -121,41 +63,60 @@ public Builder idpUrl(String idpUrl) { return this; } + /** + * Optional override for the IdP's token endpoint. When set, RFC 8414 discovery is + * skipped and this endpoint is used directly. + *

+ * Equivalent to calling {@link #tokenEndpoint(String)}. + */ public Builder idpTokenEndpoint(String idpTokenEndpoint) { - this.idpTokenEndpoint = idpTokenEndpoint; + super.tokenEndpoint(idpTokenEndpoint); + return this; + } + + @Override + public Builder tokenEndpoint(String tokenEndpoint) { + super.tokenEndpoint(tokenEndpoint); return this; } + @Override public Builder idToken(String idToken) { - this.idToken = idToken; + super.idToken(idToken); return this; } + @Override public Builder clientId(String clientId) { - this.clientId = clientId; + super.clientId(clientId); return this; } + @Override public Builder clientSecret(String clientSecret) { - this.clientSecret = clientSecret; + super.clientSecret(clientSecret); return this; } + @Override public Builder audience(String audience) { - this.audience = audience; + super.audience(audience); return this; } + @Override public Builder resource(String resource) { - this.resource = resource; + super.resource(resource); return this; } + @Override public Builder scope(String scope) { - this.scope = scope; + super.scope(scope); return this; } + @Override public DiscoverAndRequestJwtAuthGrantOptions build() { return new DiscoverAndRequestJwtAuthGrantOptions(this); } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuth.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuth.java index fe18dd0c1..fe17eba16 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuth.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuth.java @@ -12,6 +12,7 @@ import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.ArrayList; +import java.util.Base64; import java.util.List; import io.modelcontextprotocol.json.McpJsonDefaults; @@ -159,22 +160,22 @@ private static Mono fetchAuthServerMetadata(String url, Http public static Mono requestJwtAuthorizationGrant(RequestJwtAuthGrantOptions options, HttpClient httpClient) { return Mono.defer(() -> { List params = new ArrayList<>(); - params.add(encode("grant_type") + "=" + encode(GRANT_TYPE_TOKEN_EXCHANGE)); - params.add(encode("subject_token") + "=" + encode(options.getIdToken())); - params.add(encode("subject_token_type") + "=" + encode(TOKEN_TYPE_ID_TOKEN)); - params.add(encode("requested_token_type") + "=" + encode(TOKEN_TYPE_ID_JAG)); - params.add(encode("client_id") + "=" + encode(options.getClientId())); + params.add(encodeParam("grant_type", GRANT_TYPE_TOKEN_EXCHANGE)); + params.add(encodeParam("subject_token", options.getIdToken())); + params.add(encodeParam("subject_token_type", TOKEN_TYPE_ID_TOKEN)); + params.add(encodeParam("requested_token_type", TOKEN_TYPE_ID_JAG)); + params.add(encodeParam("client_id", options.getClientId())); if (options.getClientSecret() != null) { - params.add(encode("client_secret") + "=" + encode(options.getClientSecret())); + params.add(encodeParam("client_secret", options.getClientSecret())); } if (options.getAudience() != null) { - params.add(encode("audience") + "=" + encode(options.getAudience())); + params.add(encodeParam("audience", options.getAudience())); } if (options.getResource() != null) { - params.add(encode("resource") + "=" + encode(options.getResource())); + params.add(encodeParam("resource", options.getResource())); } if (options.getScope() != null) { - params.add(encode("scope") + "=" + encode(options.getScope())); + params.add(encodeParam("scope", options.getScope())); } String body = String.join("&", params); logger.debug("Requesting JAG token exchange at {}", options.getTokenEndpoint()); @@ -195,20 +196,8 @@ public static Mono requestJwtAuthorizationGrant(RequestJwtAuthGrantOptio JagTokenExchangeResponse.class); // Validate per RFC 8693 §2.2.1 - if (!TOKEN_TYPE_ID_JAG.equalsIgnoreCase(tokenResponse.getIssuedTokenType())) { - return Mono.error(new EnterpriseAuthException("Unexpected issued_token_type in JAG response: " - + tokenResponse.getIssuedTokenType() + " (expected " + TOKEN_TYPE_ID_JAG + ")")); - } - if (!"N_A".equalsIgnoreCase(tokenResponse.getTokenType())) { - return Mono.error(new EnterpriseAuthException("Unexpected token_type in JAG response: " - + tokenResponse.getTokenType() + " (expected N_A per RFC 8693 §2.2.1)")); - } - if (tokenResponse.getAccessToken() == null || tokenResponse.getAccessToken().isBlank()) { - return Mono - .error(new EnterpriseAuthException("JAG token exchange response is missing access_token")); - } - logger.debug("JAG token exchange successful"); - return Mono.just(tokenResponse.getAccessToken()); + return validateJAGTokenExchangeResponse(tokenResponse) + .doOnNext(token -> logger.debug("JAG token exchange successful")); } catch (EnterpriseAuthException e) { return Mono.error(e); @@ -234,6 +223,8 @@ public static Mono requestJwtAuthorizationGrant(RequestJwtAuthGrantOptio public static Mono discoverAndRequestJwtAuthorizationGrant(DiscoverAndRequestJwtAuthGrantOptions options, HttpClient httpClient) { Mono tokenEndpointMono; + // If the caller already discovered (or otherwise knows) the IdP token endpoint, + // skip RFC 8414 metadata discovery and use the pre-configured value directly. if (options.getIdpTokenEndpoint() != null) { tokenEndpointMono = Mono.just(options.getIdpTokenEndpoint()); } @@ -283,21 +274,25 @@ public static Mono exchangeJwtBearerGrant(Exchange HttpClient httpClient) { return Mono.defer(() -> { List params = new ArrayList<>(); - params.add(encode("grant_type") + "=" + encode(GRANT_TYPE_JWT_BEARER)); - params.add(encode("assertion") + "=" + encode(options.getAssertion())); - params.add(encode("client_id") + "=" + encode(options.getClientId())); - if (options.getClientSecret() != null) { - params.add(encode("client_secret") + "=" + encode(options.getClientSecret())); - } + params.add(encodeParam("grant_type", GRANT_TYPE_JWT_BEARER)); + params.add(encodeParam("assertion", options.getAssertion())); if (options.getScope() != null) { - params.add(encode("scope") + "=" + encode(options.getScope())); + params.add(encodeParam("scope", options.getScope())); } String body = String.join("&", params); + // Use client_secret_basic (RFC 6749 §2.3.1): send credentials in the + // Authorization header rather than the request body. This matches the + // token_endpoint_auth_method declared by the provider and is required by + // SEP-990 conformance tests. + String secret = options.getClientSecret() != null ? options.getClientSecret() : ""; + String credentials = Base64.getEncoder() + .encodeToString((options.getClientId() + ":" + secret).getBytes(StandardCharsets.UTF_8)); logger.debug("Exchanging JWT bearer grant at {}", options.getTokenEndpoint()); HttpRequest request = HttpRequest.newBuilder(URI.create(options.getTokenEndpoint())) .POST(HttpRequest.BodyPublishers.ofString(body)) .header("Content-Type", "application/x-www-form-urlencoded") .header("Accept", "application/json") + .header("Authorization", "Basic " + credentials) .build(); return Mono.fromFuture(() -> httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())); }).flatMap(response -> { @@ -318,6 +313,14 @@ public static Mono exchangeJwtBearerGrant(Exchange if (tokenResponse.getExpiresIn() != null) { tokenResponse.setExpiresAt(Instant.now().plusSeconds(tokenResponse.getExpiresIn())); } + // RFC 7523 (JWT Bearer Grant) is a stateless grant: the client presents a + // signed JWT assertion directly to obtain an access token, with no + // authorization code or refresh token involved. If the AS returns a + // refresh_token anyway, it is intentionally ignored — using it would + // allow the client to obtain new access tokens without re-validating the + // enterprise identity via the IdP, bypassing IdP session and revocation + // policies. When the access token expires, repeat the full enterprise + // auth flow to obtain a fresh token. logger.debug("JWT bearer grant exchange successful; expires_in={}", tokenResponse.getExpiresIn()); return Mono.just(tokenResponse); } @@ -334,6 +337,39 @@ public static Mono exchangeJwtBearerGrant(Exchange // Internal helpers // ----------------------------------------------------------------------- + /** + * Validates the RFC 8693 token exchange response for a JAG request. + * @param tokenResponse the parsed response + * @return a {@link Mono} emitting the {@code access_token} value, or an error of type + * {@link EnterpriseAuthException} if any validation check fails + */ + /** + * Validates the RFC 8693 token exchange response for a JAG request. + *

+ * Validates {@code issued_token_type} and the presence of {@code access_token}. + * {@code token_type} is intentionally not validated: per RFC 8693 §2.2.1 it is + * informational when the issued token is not an access token, and per RFC 6749 §5.1 + * it is case-insensitive — strict {@code N_A} checking would reject conformant IdPs + * that omit or capitalise the field differently. + */ + private static Mono validateJAGTokenExchangeResponse(JagTokenExchangeResponse tokenResponse) { + if (!TOKEN_TYPE_ID_JAG.equalsIgnoreCase(tokenResponse.getIssuedTokenType())) { + return Mono.error(new EnterpriseAuthException("Unexpected issued_token_type in JAG response: " + + tokenResponse.getIssuedTokenType() + " (expected " + TOKEN_TYPE_ID_JAG + ")")); + } + if (tokenResponse.getAccessToken() == null || tokenResponse.getAccessToken().isBlank()) { + return Mono.error(new EnterpriseAuthException("JAG token exchange response is missing access_token")); + } + return Mono.just(tokenResponse.getAccessToken()); + } + + /** + * URL-encodes a form parameter key-value pair as {@code key=value}. + */ + private static String encodeParam(String key, String value) { + return encode(key) + "=" + encode(value); + } + private static String encode(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8); } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthProvider.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthProvider.java index ecba04694..d9c5e0172 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthProvider.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthProvider.java @@ -26,8 +26,8 @@ * directly with any HTTP transport. On each request it: *

    *
  1. Checks an in-memory access token cache.
  2. - *
  3. If the cache is empty or the token is expired (within a 30-second buffer), it - * performs the full enterprise auth flow: + *
  4. If the cache is empty or the token is expired (within the + * {@code TOKEN_EXPIRY_BUFFER}), it performs the full enterprise auth flow: *
      *
    1. Discovers the MCP authorization server metadata via RFC 8414.
    2. *
    3. Invokes the {@link EnterpriseAuthProviderOptions#getAssertionCallback() assertion @@ -54,6 +54,7 @@ * .idpUrl(ctx.getAuthorizationServerUrl().toString()) * .idToken(myIdTokenSupplier.get()) * .clientId("idp-client-id") + * .clientSecret("idp-client-secret") * .build(), * httpClient); * }) @@ -77,7 +78,7 @@ public class EnterpriseAuthProvider implements McpAsyncHttpClientRequestCustomiz * Proactive refresh buffer: treat a token as expired this many seconds before its * actual expiry to avoid using a token that expires mid-flight. */ - private static final Duration EXPIRY_BUFFER = Duration.ofSeconds(30); + private static final Duration TOKEN_EXPIRY_BUFFER = Duration.ofSeconds(30); private final EnterpriseAuthProviderOptions options; @@ -152,7 +153,7 @@ private boolean isExpiredOrNearlyExpired(JwtBearerAccessTokenResponse token) { if (expiresAt == null) { return false; } - return Instant.now().isAfter(expiresAt.minus(EXPIRY_BUFFER)); + return Instant.now().isAfter(expiresAt.minus(TOKEN_EXPIRY_BUFFER)); } private Mono fetchNewToken(URI endpoint) { @@ -180,6 +181,13 @@ private Mono fetchNewToken(URI endpoint) { authServerUri); return options.getAssertionCallback().apply(assertionContext).flatMap(assertion -> { + // Note: the ID-JAG obtained from the assertionCallback is used + // immediately + // for a single access-token exchange and is not cached. If the access + // token + // is short-lived, caching the ID-JAG at the callback level can reduce IdP + // round-trips, as the JAG may still be valid when the access token + // expires. ExchangeJwtBearerGrantOptions exchangeOptions = ExchangeJwtBearerGrantOptions.builder() .tokenEndpoint(metadata.getTokenEndpoint()) .assertion(assertion) diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/ExchangeJwtBearerGrantOptions.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/ExchangeJwtBearerGrantOptions.java index 0d7e8bfa3..a3fae07ff 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/ExchangeJwtBearerGrantOptions.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/ExchangeJwtBearerGrantOptions.java @@ -13,6 +13,12 @@ * 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 {@code client_secret_basic} (RFC 6749 §2.3.1): the + * {@code client_id} and {@code client_secret} are Base64-encoded and sent in the + * {@code Authorization: Basic} header. This matches the + * {@code token_endpoint_auth_method} declared by {@code EnterpriseAuthProvider} and is + * required by SEP-990 conformance tests. * * @author MCP SDK Contributors * @see RFC 7523 diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/JagTokenExchangeResponse.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/JagTokenExchangeResponse.java index 35f1d6532..c6c7935b8 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/JagTokenExchangeResponse.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/JagTokenExchangeResponse.java @@ -13,14 +13,15 @@ * Returned by the enterprise IdP when exchanging an ID Token for a JWT Authorization * Grant (ID-JAG) during Enterprise Managed Authorization (SEP-990). *

      - * The three key fields are: + * The key fields are: *

        *
      • {@code access_token} — the issued JAG (despite the name, not an OAuth access * token)
      • *
      • {@code issued_token_type} — must be * {@code urn:ietf:params:oauth:token-type:id-jag}
      • - *
      • {@code token_type} — must be {@code N_A} (case-insensitive, per RFC 8693 - * §2.2.1)
      • + *
      • {@code token_type} — informational; per RFC 8693 §2.2.1 it SHOULD be {@code 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 casing
      • *
      * * @author MCP SDK Contributors diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/RequestJwtAuthGrantOptions.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/RequestJwtAuthGrantOptions.java index 913c4fdd5..b16d3a0f8 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/RequestJwtAuthGrantOptions.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/RequestJwtAuthGrantOptions.java @@ -38,8 +38,8 @@ public class RequestJwtAuthGrantOptions { /** The {@code scope} parameter for the token exchange request (optional). */ private final String scope; - private RequestJwtAuthGrantOptions(Builder builder) { - this.tokenEndpoint = Objects.requireNonNull(builder.tokenEndpoint, "tokenEndpoint must not be null"); + protected RequestJwtAuthGrantOptions(Builder builder) { + this.tokenEndpoint = builder.tokenEndpoint; this.idToken = Objects.requireNonNull(builder.idToken, "idToken must not be null"); this.clientId = Objects.requireNonNull(builder.clientId, "clientId must not be null"); this.clientSecret = builder.clientSecret; @@ -80,7 +80,7 @@ public static Builder builder() { return new Builder(); } - public static final class Builder { + public static class Builder { private String tokenEndpoint; @@ -96,7 +96,7 @@ public static final class Builder { private String scope; - private Builder() { + protected Builder() { } public Builder tokenEndpoint(String tokenEndpoint) { @@ -135,6 +135,7 @@ public Builder scope(String scope) { } public RequestJwtAuthGrantOptions build() { + Objects.requireNonNull(tokenEndpoint, "tokenEndpoint must not be null"); return new RequestJwtAuthGrantOptions(this); } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/auth/EnterpriseAuthTest.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/auth/EnterpriseAuthTest.java index f7be577cd..1c96b85a5 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/auth/EnterpriseAuthTest.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/auth/EnterpriseAuthTest.java @@ -196,7 +196,9 @@ void requestJwtAuthorizationGrant_wrongIssuedTokenType_emitsError() { } @Test - void requestJwtAuthorizationGrant_wrongTokenType_emitsError() { + void requestJwtAuthorizationGrant_nonStandardTokenType_succeeds() { + // 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. server.createContext("/token", exchange -> sendJson(exchange, 200, """ { "access_token": "tok", @@ -211,8 +213,8 @@ void requestJwtAuthorizationGrant_wrongTokenType_emitsError() { .build(); StepVerifier.create(EnterpriseAuth.requestJwtAuthorizationGrant(options, httpClient)) - .expectErrorMatches(e -> e instanceof EnterpriseAuthException && e.getMessage().contains("token_type")) - .verify(); + .expectNext("tok") + .verifyComplete(); } @Test @@ -289,7 +291,15 @@ void exchangeJwtBearerGrant_success() { String body = new String(exchange.getRequestBody().readAllBytes()); assertThat(body).contains("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer"); assertThat(body).contains("assertion=my-jag"); - assertThat(body).contains("client_id=cid"); + // client credentials must be sent via Basic auth header + // (client_secret_basic), + // not in the request body (client_secret_post) + assertThat(body).doesNotContain("client_id"); + String authHeader = exchange.getRequestHeaders().getFirst("Authorization"); + assertThat(authHeader).isNotNull(); + assertThat(authHeader).startsWith("Basic "); + String decoded = new String(java.util.Base64.getDecoder().decode(authHeader.substring(6))); + assertThat(decoded).isEqualTo("cid:"); sendJson(exchange, 200, """ { @@ -498,6 +508,86 @@ void enterpriseAuthProvider_assertionCallbackError_emitsError() { .verify(); } + @Test + void enterpriseAuthProvider_nearlyExpiredToken_fetchesNewToken() { + // expires_in=0 means the token expires immediately; with the 30-second + // TOKEN_EXPIRY_BUFFER it is considered expired on every call, forcing a re-fetch. + int[] callCount = { 0 }; + + server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, + "{\"issuer\":\"" + baseUrl + "\",\"token_endpoint\":\"" + baseUrl + "/mcp-token\"}")); + server.createContext("/mcp-token", exchange -> { + callCount[0]++; + sendJson(exchange, 200, """ + { + "access_token": "expiring-token", + "token_type": "Bearer", + "expires_in": 0 + }"""); + }); + + EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() + .clientId("client-id") + .assertionCallback(ctx -> Mono.just("jag")) + .build(); + + EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); + URI endpoint = URI.create(baseUrl + "/mcp"); + + // First request — fetches a token that expires within the buffer window + Mono.from( + provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) + .block(); + assertThat(callCount[0]).isEqualTo(1); + + // Second request — cached token is already within the expiry buffer, must + // re-fetch + Mono.from( + provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) + .block(); + assertThat(callCount[0]).isEqualTo(2); + } + + @Test + void enterpriseAuthProvider_tokenWithoutExpiresIn_usesCache() { + // When the server omits expires_in the token has no expiry and is kept in cache + // indefinitely (until invalidated). + int[] callCount = { 0 }; + + server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, + "{\"issuer\":\"" + baseUrl + "\",\"token_endpoint\":\"" + baseUrl + "/mcp-token\"}")); + server.createContext("/mcp-token", exchange -> { + callCount[0]++; + sendJson(exchange, 200, """ + { + "access_token": "no-expiry-token", + "token_type": "Bearer" + }"""); + }); + + EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() + .clientId("client-id") + .assertionCallback(ctx -> Mono.just("jag")) + .build(); + + EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); + URI endpoint = URI.create(baseUrl + "/mcp"); + + // First request fetches and caches the token + Mono.from( + provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) + .block(); + // Subsequent requests must reuse the cached token without re-fetching + Mono.from( + provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) + .block(); + Mono.from( + provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) + .block(); + + assertThat(callCount[0]).isEqualTo(1); + } + // ----------------------------------------------------------------------- // EnterpriseAuthProviderOptions — validation // ----------------------------------------------------------------------- From 649a4261ee2633bfa94798fa1281195ed337455c Mon Sep 17 00:00:00 2001 From: Prachi Pandey Date: Tue, 17 Mar 2026 00:29:18 +0530 Subject: [PATCH 3/4] Split EnterpriseAuthProvider tests into separate test class --- .../auth/EnterpriseAuthProviderTest.java | 342 ++++++++++++++++++ .../client/auth/EnterpriseAuthTest.java | 272 +------------- 2 files changed, 343 insertions(+), 271 deletions(-) create mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/client/auth/EnterpriseAuthProviderTest.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/auth/EnterpriseAuthProviderTest.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/auth/EnterpriseAuthProviderTest.java new file mode 100644 index 000000000..4d2f32593 --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/auth/EnterpriseAuthProviderTest.java @@ -0,0 +1,342 @@ +/* + * Copyright 2024-2025 the original author or authors. + */ + +package io.modelcontextprotocol.client.auth; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import io.modelcontextprotocol.common.McpTransportContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Tests for {@link EnterpriseAuthProvider}. + * + * @author MCP SDK Contributors + */ +class EnterpriseAuthProviderTest { + + private HttpServer server; + + private String baseUrl; + + private HttpClient httpClient; + + @BeforeEach + void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress(0), 0); + server.start(); + int port = server.getAddress().getPort(); + baseUrl = "http://localhost:" + port; + httpClient = HttpClient.newHttpClient(); + } + + @AfterEach + void stopServer() { + if (server != null) { + server.stop(0); + } + } + + // ----------------------------------------------------------------------- + // EnterpriseAuthProvider + // ----------------------------------------------------------------------- + + @Test + void enterpriseAuthProvider_injectsAuthorizationHeader() { + // Auth server discovery + server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, + "{\"issuer\":\"" + baseUrl + "\",\"token_endpoint\":\"" + baseUrl + "/mcp-token\"}")); + // JWT bearer grant exchange + server.createContext("/mcp-token", exchange -> sendJson(exchange, 200, """ + { + "access_token": "final-access-token", + "token_type": "Bearer", + "expires_in": 3600 + }""")); + + // The assertion callback simulates having already obtained a JAG from the IdP + EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() + .clientId("client-id") + .assertionCallback(ctx -> Mono.just("pre-obtained-jag")) + .build(); + + EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); + + URI endpoint = URI.create(baseUrl + "/mcp"); + HttpRequest.Builder builder = HttpRequest.newBuilder(endpoint); + + StepVerifier + .create(Mono.from(provider.customize(builder, "POST", endpoint, "{}", McpTransportContext.EMPTY)) + .map(HttpRequest.Builder::build) + .map(req -> req.headers().firstValue("Authorization").orElse(null))) + .expectNext("Bearer final-access-token") + .verifyComplete(); + } + + @Test + void enterpriseAuthProvider_cachesPreviousToken() { + int[] callCount = { 0 }; + + server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, + "{\"issuer\":\"" + baseUrl + "\",\"token_endpoint\":\"" + baseUrl + "/mcp-token\"}")); + server.createContext("/mcp-token", exchange -> { + callCount[0]++; + sendJson(exchange, 200, """ + { + "access_token": "cached-token", + "token_type": "Bearer", + "expires_in": 3600 + }"""); + }); + + EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() + .clientId("client-id") + .assertionCallback(ctx -> Mono.just("jag")) + .build(); + + EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); + + URI endpoint = URI.create(baseUrl + "/mcp"); + HttpRequest.Builder builder1 = HttpRequest.newBuilder(endpoint); + HttpRequest.Builder builder2 = HttpRequest.newBuilder(endpoint); + + // First request — fetches token + Mono.from(provider.customize(builder1, "POST", endpoint, null, McpTransportContext.EMPTY)).block(); + // Second request — should use cache + Mono.from(provider.customize(builder2, "POST", endpoint, null, McpTransportContext.EMPTY)).block(); + + assertThat(callCount[0]).isEqualTo(1); + } + + @Test + void enterpriseAuthProvider_invalidateCache_forcesRefetch() { + int[] callCount = { 0 }; + + server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, + "{\"issuer\":\"" + baseUrl + "\",\"token_endpoint\":\"" + baseUrl + "/mcp-token\"}")); + server.createContext("/mcp-token", exchange -> { + callCount[0]++; + sendJson(exchange, 200, """ + { + "access_token": "refreshed-token", + "token_type": "Bearer", + "expires_in": 3600 + }"""); + }); + + EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() + .clientId("client-id") + .assertionCallback(ctx -> Mono.just("jag")) + .build(); + + EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); + + URI endpoint = URI.create(baseUrl + "/mcp"); + + // First request + Mono.from( + provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) + .block(); + assertThat(callCount[0]).isEqualTo(1); + + // Invalidate + provider.invalidateCache(); + + // Second request — cache cleared, must fetch again + Mono.from( + provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) + .block(); + assertThat(callCount[0]).isEqualTo(2); + } + + @Test + void enterpriseAuthProvider_discoveryFails_emitsError() { + server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 500, "")); + server.createContext("/.well-known/openid-configuration", exchange -> sendJson(exchange, 500, "")); + + EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() + .clientId("cid") + .assertionCallback(ctx -> Mono.just("jag")) + .build(); + + EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); + URI endpoint = URI.create(baseUrl + "/mcp"); + + StepVerifier.create(Mono.from( + provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY))) + .expectErrorMatches(e -> e instanceof EnterpriseAuthException) + .verify(); + } + + @Test + void enterpriseAuthProvider_assertionCallbackError_emitsError() { + server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, + "{\"issuer\":\"" + baseUrl + "\",\"token_endpoint\":\"" + baseUrl + "/mcp-token\"}")); + + EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() + .clientId("cid") + .assertionCallback(ctx -> Mono.error(new RuntimeException("IdP unreachable"))) + .build(); + + EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); + URI endpoint = URI.create(baseUrl + "/mcp"); + + StepVerifier + .create(Mono.from(provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, + McpTransportContext.EMPTY))) + .expectErrorMatches(e -> e instanceof RuntimeException && e.getMessage().contains("IdP unreachable")) + .verify(); + } + + @Test + void enterpriseAuthProvider_nearlyExpiredToken_fetchesNewToken() { + // expires_in=0 means the token expires immediately; with the 30-second + // TOKEN_EXPIRY_BUFFER it is considered expired on every call, forcing a re-fetch. + int[] callCount = { 0 }; + + server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, + "{\"issuer\":\"" + baseUrl + "\",\"token_endpoint\":\"" + baseUrl + "/mcp-token\"}")); + server.createContext("/mcp-token", exchange -> { + callCount[0]++; + sendJson(exchange, 200, """ + { + "access_token": "expiring-token", + "token_type": "Bearer", + "expires_in": 0 + }"""); + }); + + EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() + .clientId("client-id") + .assertionCallback(ctx -> Mono.just("jag")) + .build(); + + EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); + URI endpoint = URI.create(baseUrl + "/mcp"); + + // First request — fetches a token that expires within the buffer window + Mono.from( + provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) + .block(); + assertThat(callCount[0]).isEqualTo(1); + + // Second request — cached token is already within the expiry buffer, must + // re-fetch + Mono.from( + provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) + .block(); + assertThat(callCount[0]).isEqualTo(2); + } + + @Test + void enterpriseAuthProvider_tokenWithoutExpiresIn_usesCache() { + // When the server omits expires_in the token has no expiry and is kept in cache + // indefinitely (until invalidated). + int[] callCount = { 0 }; + + server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, + "{\"issuer\":\"" + baseUrl + "\",\"token_endpoint\":\"" + baseUrl + "/mcp-token\"}")); + server.createContext("/mcp-token", exchange -> { + callCount[0]++; + sendJson(exchange, 200, """ + { + "access_token": "no-expiry-token", + "token_type": "Bearer" + }"""); + }); + + EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() + .clientId("client-id") + .assertionCallback(ctx -> Mono.just("jag")) + .build(); + + EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); + URI endpoint = URI.create(baseUrl + "/mcp"); + + // First request fetches and caches the token + Mono.from( + provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) + .block(); + // Subsequent requests must reuse the cached token without re-fetching + Mono.from( + provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) + .block(); + Mono.from( + provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) + .block(); + + assertThat(callCount[0]).isEqualTo(1); + } + + // ----------------------------------------------------------------------- + // EnterpriseAuthProviderOptions — validation + // ----------------------------------------------------------------------- + + @Test + void providerOptions_nullClientId_throws() { + assertThatThrownBy( + () -> EnterpriseAuthProviderOptions.builder().assertionCallback(ctx -> Mono.just("j")).build()) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("clientId"); + } + + @Test + void providerOptions_nullCallback_throws() { + assertThatThrownBy(() -> EnterpriseAuthProviderOptions.builder().clientId("cid").build()) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("assertionCallback"); + } + + // ----------------------------------------------------------------------- + // JwtBearerAccessTokenResponse helpers + // ----------------------------------------------------------------------- + + @Test + void jwtBearerAccessTokenResponse_isExpired_whenPastExpiresAt() { + JwtBearerAccessTokenResponse response = new JwtBearerAccessTokenResponse(); + response.setAccessToken("tok"); + response.setExpiresAt(java.time.Instant.now().minusSeconds(10)); + assertThat(response.isExpired()).isTrue(); + } + + @Test + void jwtBearerAccessTokenResponse_notExpired_whenNoExpiresAt() { + JwtBearerAccessTokenResponse response = new JwtBearerAccessTokenResponse(); + response.setAccessToken("tok"); + assertThat(response.isExpired()).isFalse(); + } + + // ----------------------------------------------------------------------- + // Helper + // ----------------------------------------------------------------------- + + private static void sendJson(HttpExchange exchange, int statusCode, String body) { + try { + byte[] bytes = body.getBytes(java.nio.charset.StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(statusCode, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(bytes); + } + } + catch (IOException e) { + throw new RuntimeException(e); + } + } + +} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/auth/EnterpriseAuthTest.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/auth/EnterpriseAuthTest.java index 1c96b85a5..619e42c16 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/auth/EnterpriseAuthTest.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/auth/EnterpriseAuthTest.java @@ -21,10 +21,9 @@ import reactor.test.StepVerifier; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; /** - * Tests for {@link EnterpriseAuth} and {@link EnterpriseAuthProvider}. + * Tests for {@link EnterpriseAuth}. * * @author MCP SDK Contributors */ @@ -357,275 +356,6 @@ void exchangeJwtBearerGrant_httpError_emitsError() { .verify(); } - // ----------------------------------------------------------------------- - // EnterpriseAuthProvider - // ----------------------------------------------------------------------- - - @Test - void enterpriseAuthProvider_injectsAuthorizationHeader() { - // Auth server discovery - server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, - "{\"issuer\":\"" + baseUrl + "\",\"token_endpoint\":\"" + baseUrl + "/mcp-token\"}")); - // JWT bearer grant exchange - server.createContext("/mcp-token", exchange -> sendJson(exchange, 200, """ - { - "access_token": "final-access-token", - "token_type": "Bearer", - "expires_in": 3600 - }""")); - - // The assertion callback simulates having already obtained a JAG from the IdP - EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() - .clientId("client-id") - .assertionCallback(ctx -> Mono.just("pre-obtained-jag")) - .build(); - - EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); - - URI endpoint = URI.create(baseUrl + "/mcp"); - HttpRequest.Builder builder = HttpRequest.newBuilder(endpoint); - - StepVerifier - .create(Mono.from(provider.customize(builder, "POST", endpoint, "{}", McpTransportContext.EMPTY)) - .map(HttpRequest.Builder::build) - .map(req -> req.headers().firstValue("Authorization").orElse(null))) - .expectNext("Bearer final-access-token") - .verifyComplete(); - } - - @Test - void enterpriseAuthProvider_cachesPreviousToken() { - int[] callCount = { 0 }; - - server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, - "{\"issuer\":\"" + baseUrl + "\",\"token_endpoint\":\"" + baseUrl + "/mcp-token\"}")); - server.createContext("/mcp-token", exchange -> { - callCount[0]++; - sendJson(exchange, 200, """ - { - "access_token": "cached-token", - "token_type": "Bearer", - "expires_in": 3600 - }"""); - }); - - EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() - .clientId("client-id") - .assertionCallback(ctx -> Mono.just("jag")) - .build(); - - EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); - - URI endpoint = URI.create(baseUrl + "/mcp"); - HttpRequest.Builder builder1 = HttpRequest.newBuilder(endpoint); - HttpRequest.Builder builder2 = HttpRequest.newBuilder(endpoint); - - // First request — fetches token - Mono.from(provider.customize(builder1, "POST", endpoint, null, McpTransportContext.EMPTY)).block(); - // Second request — should use cache - Mono.from(provider.customize(builder2, "POST", endpoint, null, McpTransportContext.EMPTY)).block(); - - assertThat(callCount[0]).isEqualTo(1); - } - - @Test - void enterpriseAuthProvider_invalidateCache_forcesRefetch() { - int[] callCount = { 0 }; - - server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, - "{\"issuer\":\"" + baseUrl + "\",\"token_endpoint\":\"" + baseUrl + "/mcp-token\"}")); - server.createContext("/mcp-token", exchange -> { - callCount[0]++; - sendJson(exchange, 200, """ - { - "access_token": "refreshed-token", - "token_type": "Bearer", - "expires_in": 3600 - }"""); - }); - - EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() - .clientId("client-id") - .assertionCallback(ctx -> Mono.just("jag")) - .build(); - - EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); - - URI endpoint = URI.create(baseUrl + "/mcp"); - - // First request - Mono.from( - provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) - .block(); - assertThat(callCount[0]).isEqualTo(1); - - // Invalidate - provider.invalidateCache(); - - // Second request — cache cleared, must fetch again - Mono.from( - provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) - .block(); - assertThat(callCount[0]).isEqualTo(2); - } - - @Test - void enterpriseAuthProvider_discoveryFails_emitsError() { - server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 500, "")); - server.createContext("/.well-known/openid-configuration", exchange -> sendJson(exchange, 500, "")); - - EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() - .clientId("cid") - .assertionCallback(ctx -> Mono.just("jag")) - .build(); - - EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); - URI endpoint = URI.create(baseUrl + "/mcp"); - - StepVerifier.create(Mono.from( - provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY))) - .expectErrorMatches(e -> e instanceof EnterpriseAuthException) - .verify(); - } - - @Test - void enterpriseAuthProvider_assertionCallbackError_emitsError() { - server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, - "{\"issuer\":\"" + baseUrl + "\",\"token_endpoint\":\"" + baseUrl + "/mcp-token\"}")); - - EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() - .clientId("cid") - .assertionCallback(ctx -> Mono.error(new RuntimeException("IdP unreachable"))) - .build(); - - EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); - URI endpoint = URI.create(baseUrl + "/mcp"); - - StepVerifier - .create(Mono.from(provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, - McpTransportContext.EMPTY))) - .expectErrorMatches(e -> e instanceof RuntimeException && e.getMessage().contains("IdP unreachable")) - .verify(); - } - - @Test - void enterpriseAuthProvider_nearlyExpiredToken_fetchesNewToken() { - // expires_in=0 means the token expires immediately; with the 30-second - // TOKEN_EXPIRY_BUFFER it is considered expired on every call, forcing a re-fetch. - int[] callCount = { 0 }; - - server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, - "{\"issuer\":\"" + baseUrl + "\",\"token_endpoint\":\"" + baseUrl + "/mcp-token\"}")); - server.createContext("/mcp-token", exchange -> { - callCount[0]++; - sendJson(exchange, 200, """ - { - "access_token": "expiring-token", - "token_type": "Bearer", - "expires_in": 0 - }"""); - }); - - EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() - .clientId("client-id") - .assertionCallback(ctx -> Mono.just("jag")) - .build(); - - EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); - URI endpoint = URI.create(baseUrl + "/mcp"); - - // First request — fetches a token that expires within the buffer window - Mono.from( - provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) - .block(); - assertThat(callCount[0]).isEqualTo(1); - - // Second request — cached token is already within the expiry buffer, must - // re-fetch - Mono.from( - provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) - .block(); - assertThat(callCount[0]).isEqualTo(2); - } - - @Test - void enterpriseAuthProvider_tokenWithoutExpiresIn_usesCache() { - // When the server omits expires_in the token has no expiry and is kept in cache - // indefinitely (until invalidated). - int[] callCount = { 0 }; - - server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, - "{\"issuer\":\"" + baseUrl + "\",\"token_endpoint\":\"" + baseUrl + "/mcp-token\"}")); - server.createContext("/mcp-token", exchange -> { - callCount[0]++; - sendJson(exchange, 200, """ - { - "access_token": "no-expiry-token", - "token_type": "Bearer" - }"""); - }); - - EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() - .clientId("client-id") - .assertionCallback(ctx -> Mono.just("jag")) - .build(); - - EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); - URI endpoint = URI.create(baseUrl + "/mcp"); - - // First request fetches and caches the token - Mono.from( - provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) - .block(); - // Subsequent requests must reuse the cached token without re-fetching - Mono.from( - provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) - .block(); - Mono.from( - provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) - .block(); - - assertThat(callCount[0]).isEqualTo(1); - } - - // ----------------------------------------------------------------------- - // EnterpriseAuthProviderOptions — validation - // ----------------------------------------------------------------------- - - @Test - void providerOptions_nullClientId_throws() { - assertThatThrownBy( - () -> EnterpriseAuthProviderOptions.builder().assertionCallback(ctx -> Mono.just("j")).build()) - .isInstanceOf(NullPointerException.class) - .hasMessageContaining("clientId"); - } - - @Test - void providerOptions_nullCallback_throws() { - assertThatThrownBy(() -> EnterpriseAuthProviderOptions.builder().clientId("cid").build()) - .isInstanceOf(NullPointerException.class) - .hasMessageContaining("assertionCallback"); - } - - // ----------------------------------------------------------------------- - // JwtBearerAccessTokenResponse helpers - // ----------------------------------------------------------------------- - - @Test - void jwtBearerAccessTokenResponse_isExpired_whenPastExpiresAt() { - JwtBearerAccessTokenResponse response = new JwtBearerAccessTokenResponse(); - response.setAccessToken("tok"); - response.setExpiresAt(java.time.Instant.now().minusSeconds(10)); - assertThat(response.isExpired()).isTrue(); - } - - @Test - void jwtBearerAccessTokenResponse_notExpired_whenNoExpiresAt() { - JwtBearerAccessTokenResponse response = new JwtBearerAccessTokenResponse(); - response.setAccessToken("tok"); - assertThat(response.isExpired()).isFalse(); - } - // ----------------------------------------------------------------------- // Helper // ----------------------------------------------------------------------- From 20c5628aec5587a9ffdb9f813a1edf14041cff85 Mon Sep 17 00:00:00 2001 From: Prachi Pandey Date: Wed, 18 Mar 2026 11:13:55 +0530 Subject: [PATCH 4/4] Add auth/cross-app-access-complete-flow to JDK conformance client (SEP-990) --- .../client-jdk-http-client/pom.xml | 7 ++ .../client/ConformanceJdkClientMcpClient.java | 89 +++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/conformance-tests/client-jdk-http-client/pom.xml b/conformance-tests/client-jdk-http-client/pom.xml index f939cfa6c..c30c0f608 100644 --- a/conformance-tests/client-jdk-http-client/pom.xml +++ b/conformance-tests/client-jdk-http-client/pom.xml @@ -31,6 +31,13 @@ 2.0.0-SNAPSHOT + + + io.modelcontextprotocol.sdk + mcp-core + 2.0.0-SNAPSHOT + + ch.qos.logback diff --git a/conformance-tests/client-jdk-http-client/src/main/java/io/modelcontextprotocol/conformance/client/ConformanceJdkClientMcpClient.java b/conformance-tests/client-jdk-http-client/src/main/java/io/modelcontextprotocol/conformance/client/ConformanceJdkClientMcpClient.java index 570c4614e..41485cd3e 100644 --- a/conformance-tests/client-jdk-http-client/src/main/java/io/modelcontextprotocol/conformance/client/ConformanceJdkClientMcpClient.java +++ b/conformance-tests/client-jdk-http-client/src/main/java/io/modelcontextprotocol/conformance/client/ConformanceJdkClientMcpClient.java @@ -2,8 +2,15 @@ import java.time.Duration; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; import io.modelcontextprotocol.client.McpClient; import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.client.auth.DiscoverAndRequestJwtAuthGrantOptions; +import io.modelcontextprotocol.client.auth.EnterpriseAuth; +import io.modelcontextprotocol.client.auth.EnterpriseAuthProvider; +import io.modelcontextprotocol.client.auth.EnterpriseAuthProviderOptions; import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; import io.modelcontextprotocol.spec.McpSchema; @@ -53,6 +60,9 @@ public static void main(String[] args) { case "sse-retry": runSSERetryScenario(serverUrl); break; + case "auth/cross-app-access-complete-flow": + runCrossAppAccessCompleteFlowScenario(serverUrl); + break; default: System.err.println("Unknown scenario: " + scenario); System.err.println("Available scenarios:"); @@ -60,6 +70,7 @@ public static void main(String[] args) { System.err.println(" - tools_call"); System.err.println(" - elicitation-sep1034-client-defaults"); System.err.println(" - sse-retry"); + System.err.println(" - auth/cross-app-access-complete-flow"); System.exit(1); } System.exit(0); @@ -283,4 +294,82 @@ private static void runSSERetryScenario(String serverUrl) throws Exception { } } + /** + * Cross-App Access scenario: Tests SEP-990 Enterprise Managed Authorization flow. + *

      + * Reads context from {@code MCP_CONFORMANCE_CONTEXT} (JSON) containing: + * {@code client_id}, {@code client_secret}, {@code idp_client_id}, + * {@code idp_id_token}, {@code idp_issuer}, {@code idp_token_endpoint}. + *

      + * Uses {@link EnterpriseAuthProvider} with an assertion callback that performs RFC + * 8693 token exchange at the IdP, then exchanges the ID-JAG for an access token at + * the MCP authorization server via RFC 7523 JWT Bearer grant. + * @param serverUrl the URL of the MCP server + * @throws Exception if any error occurs during execution + */ + private static void runCrossAppAccessCompleteFlowScenario(String serverUrl) throws Exception { + String contextEnv = System.getenv("MCP_CONFORMANCE_CONTEXT"); + if (contextEnv == null || contextEnv.isEmpty()) { + System.err.println("Error: MCP_CONFORMANCE_CONTEXT environment variable is not set"); + System.exit(1); + } + + CrossAppAccessContext ctx = new ObjectMapper().readValue(contextEnv, CrossAppAccessContext.class); + + java.net.http.HttpClient httpClient = java.net.http.HttpClient.newHttpClient(); + + EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() + .clientId(ctx.clientId()) + .clientSecret(ctx.clientSecret()) + .assertionCallback(assertionCtx -> { + // RFC 8693 token exchange at the IdP: ID Token → ID-JAG + DiscoverAndRequestJwtAuthGrantOptions jagOptions = DiscoverAndRequestJwtAuthGrantOptions + .builder() + .idpUrl(ctx.idpIssuer()) + .idpTokenEndpoint(ctx.idpTokenEndpoint()) + .idToken(ctx.idpIdToken()) + .clientId(ctx.idpClientId()) + .audience(assertionCtx.getAuthorizationServerUrl().toString()) + .resource(assertionCtx.getResourceUrl().toString()) + .build(); + return EnterpriseAuth.discoverAndRequestJwtAuthorizationGrant(jagOptions, httpClient); + }) + .build(); + + EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); + + HttpClientStreamableHttpTransport transport = HttpClientStreamableHttpTransport.builder(serverUrl) + .httpRequestCustomizer(provider) + .build(); + + McpSyncClient client = McpClient.sync(transport) + .clientInfo(new McpSchema.Implementation("test-client", "1.0.0")) + .requestTimeout(Duration.ofSeconds(30)) + .build(); + + try { + client.initialize(); + System.out.println("Successfully connected to MCP server"); + + client.listTools(); + System.out.println("Successfully listed tools"); + } + finally { + client.close(); + System.out.println("Connection closed successfully"); + } + } + + /** + * Context provided by the conformance suite for the cross-app-access-complete-flow + * scenario via the {@code MCP_CONFORMANCE_CONTEXT} environment variable. + */ + @JsonIgnoreProperties(ignoreUnknown = true) + private record CrossAppAccessContext(@JsonProperty("client_id") String clientId, + @JsonProperty("client_secret") String clientSecret, + @JsonProperty("idp_client_id") String idpClientId, + @JsonProperty("idp_id_token") String idpIdToken, @JsonProperty("idp_issuer") String idpIssuer, + @JsonProperty("idp_token_endpoint") String idpTokenEndpoint) { + } + }