+ * 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) { + } + } 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..22f5921b3 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/DiscoverAndRequestJwtAuthGrantOptions.java @@ -0,0 +1,126 @@ +/* + * Copyright 2024-2025 the original author or authors. + */ + +package io.modelcontextprotocol.client.auth; + +import java.util.Objects; + +/** + * Options for {@link EnterpriseAuth#discoverAndRequestJwtAuthorizationGrant} — extends + * {@link RequestJwtAuthGrantOptions} with IdP discovery support. + *
+ * 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 extends RequestJwtAuthGrantOptions { + + /** + * 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; + + private DiscoverAndRequestJwtAuthGrantOptions(Builder builder) { + super(builder); + this.idpUrl = Objects.requireNonNull(builder.idpUrl, "idpUrl must not be null"); + } + + 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 getTokenEndpoint(); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder extends RequestJwtAuthGrantOptions.Builder { + + private String idpUrl; + + private Builder() { + } + + public Builder idpUrl(String idpUrl) { + this.idpUrl = 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) { + super.tokenEndpoint(idpTokenEndpoint); + return this; + } + + @Override + public Builder tokenEndpoint(String tokenEndpoint) { + super.tokenEndpoint(tokenEndpoint); + return this; + } + + @Override + public Builder idToken(String idToken) { + super.idToken(idToken); + return this; + } + + @Override + public Builder clientId(String clientId) { + super.clientId(clientId); + return this; + } + + @Override + public Builder clientSecret(String clientSecret) { + super.clientSecret(clientSecret); + return this; + } + + @Override + public Builder audience(String audience) { + super.audience(audience); + return this; + } + + @Override + public Builder resource(String resource) { + super.resource(resource); + return this; + } + + @Override + public Builder scope(String 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 new file mode 100644 index 000000000..fe17eba16 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuth.java @@ -0,0 +1,377 @@ +/* + * 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.Base64; +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: + *
+ * 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
+ * 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
+ * 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
+ * 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
+ * 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
+ * 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..d9c5e0172
--- /dev/null
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthProvider.java
@@ -0,0 +1,213 @@
+/*
+ * 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:
+ *
+ * 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
+ * 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
+ * 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
+ * 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
+ */
+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..c6c7935b8
--- /dev/null
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/JagTokenExchangeResponse.java
@@ -0,0 +1,88 @@
+/*
+ * 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 key fields are:
+ *
+ * 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..b16d3a0f8
--- /dev/null
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/RequestJwtAuthGrantOptions.java
@@ -0,0 +1,144 @@
+/*
+ * 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;
+
+ 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;
+ 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 class Builder {
+
+ private String tokenEndpoint;
+
+ private String idToken;
+
+ private String clientId;
+
+ private String clientSecret;
+
+ private String audience;
+
+ private String resource;
+
+ private String scope;
+
+ protected 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() {
+ Objects.requireNonNull(tokenEndpoint, "tokenEndpoint must not be null");
+ return new RequestJwtAuthGrantOptions(this);
+ }
+
+ }
+
+}
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
new file mode 100644
index 000000000..619e42c16
--- /dev/null
+++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/auth/EnterpriseAuthTest.java
@@ -0,0 +1,377 @@
+/*
+ * 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;
+
+/**
+ * Tests for {@link EnterpriseAuth}.
+ *
+ * @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_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",
+ "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))
+ .expectNext("tok")
+ .verifyComplete();
+ }
+
+ @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");
+ // 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, """
+ {
+ "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();
+ }
+
+ // -----------------------------------------------------------------------
+ // 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
+ *
+ *
+ *
+ *
+ * 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")
+ * .clientSecret("idp-client-secret")
+ * .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 TOKEN_EXPIRY_BUFFER = Duration.ofSeconds(30);
+
+ private final EnterpriseAuthProviderOptions options;
+
+ private final HttpClient httpClient;
+
+ private final AtomicReference
+ *
+ *
+ * @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.
+ *