diff --git a/README.md b/README.md
index 7f5a9e14e..e465fd9c9 100644
--- a/README.md
+++ b/README.md
@@ -31,6 +31,11 @@ For more information about MCP:
- [Protocol Specification](https://modelcontextprotocol.io/specification/)
- [GitHub Organization](https://github.com/modelcontextprotocol)
+## Cross-Application Access (Identity Assertion Authorization Grant flow)
+
+The SDK provides support for the [Identity Assertion Authorization Grant flow](https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/enterprise-managed-authorization.mdx)
+via `CrossApplicationAccessProvider`. See the [Cross-Application Access](docs/concepts/transports/transports.md#cross-application-access) section in the transport docs for full usage details.
+
## License
This project is licensed under the [Apache License 2.0](LICENSE).
diff --git a/docs/concepts/transports/transports.md b/docs/concepts/transports/transports.md
index 00df2981c..034e1c2ef 100644
--- a/docs/concepts/transports/transports.md
+++ b/docs/concepts/transports/transports.md
@@ -378,3 +378,42 @@ Console.WriteLine(await echo.InvokeAsync(new() { ["arg"] = "Hello World" }));
```
Like [stdio](#stdio-transport), the in-memory transport is inherently single-session — there is no `Mcp-Session-Id` header, and server-to-client requests (sampling, elicitation, roots) work naturally over the bidirectional pipe. This makes it ideal for testing servers that depend on these features. See [Sessions](xref:stateless) for how session behavior varies across transports.
+
+## Cross-Application Access
+
+The SDK provides built-in support for the [Identity Assertion Authorization Grant (IDAG) flow](https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/enterprise-managed-authorization.mdx) via `CrossApplicationAccessProvider`. This enables non-interactive enterprise SSO scenarios where users authenticate once via their enterprise Identity Provider (IdP) and access MCP servers without per-server authorization prompts.
+
+The flow consists of two steps:
+1. **RFC 8693 Token Exchange** at the enterprise IdP: OIDC ID token → JWT Authorization Grant (JAG)
+2. **RFC 7523 JWT Bearer Grant** at the MCP authorization server: JAG → access token
+
+### Usage
+
+```csharp
+using ModelContextProtocol.Authentication;
+
+// The caller owns the HttpClient lifetime.
+var httpClient = new HttpClient();
+
+var provider = new CrossApplicationAccessProvider(
+ new CrossApplicationAccessProviderOptions
+ {
+ ClientId = "mcp-client-id",
+ IdpTokenEndpoint = "https://company.okta.com/oauth2/token",
+ IdpClientId = "idp-client-id",
+ IdTokenCallback = (context, cancellationToken) =>
+ // Fetch a fresh ID token from your SSO session.
+ mySsoClient.GetIdTokenAsync(cancellationToken)
+ },
+ httpClient);
+
+var tokens = await provider.GetAccessTokenAsync(
+ resourceUrl: new Uri("https://mcp-server.example.com"),
+ authorizationServerUrl: new Uri("https://auth.mcp-server.example.com"),
+ cancellationToken: ct);
+
+// Use tokens.AccessToken to authenticate against the MCP server.
+// Call provider.InvalidateCache() to force a fresh token exchange on the next call.
+```
+
+The provider caches the resulting access token and reuses it until it expires. To force re-authentication (e.g. after a 401 response), call `provider.InvalidateCache()` before retrying.
diff --git a/src/ModelContextProtocol.Core/Authentication/CrossApplicationAccess.cs b/src/ModelContextProtocol.Core/Authentication/CrossApplicationAccess.cs
new file mode 100644
index 000000000..2cc862413
--- /dev/null
+++ b/src/ModelContextProtocol.Core/Authentication/CrossApplicationAccess.cs
@@ -0,0 +1,296 @@
+using System.Net.Http.Headers;
+using System.Text.Json;
+
+namespace ModelContextProtocol.Authentication;
+
+///
+/// Provides internal utilities for the Cross-Application Access authorization flow.
+///
+///
+/// Implements the Enterprise Managed Authorization flow as specified at
+/// .
+///
+internal static class CrossApplicationAccess
+{
+ #region Constants
+
+ /// Grant type URN for RFC 8693 token exchange.
+ public const string GrantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange";
+
+ /// Grant type URN for RFC 7523 JWT Bearer authorization grant.
+ public const string GrantTypeJwtBearer = "urn:ietf:params:oauth:grant-type:jwt-bearer";
+
+ /// Token type URN for OpenID Connect ID Tokens (RFC 8693).
+ public const string TokenTypeIdToken = "urn:ietf:params:oauth:token-type:id_token";
+
+ /// Token type URN for SAML 2.0 assertions (RFC 8693).
+ public const string TokenTypeSaml2 = "urn:ietf:params:oauth:token-type:saml2";
+
+ ///
+ /// Token type URN for Identity Assertion JWT Authorization Grants.
+ /// As specified at
+ /// .
+ ///
+ public const string TokenTypeIdJag = "urn:ietf:params:oauth:token-type:id-jag";
+
+ ///
+ /// The expected value for token_type in a JAG token exchange response per RFC 8693 §2.2.1.
+ /// The issued token is not an OAuth access token, so its type is "N_A".
+ ///
+ public const string TokenTypeNotApplicable = "N_A";
+
+ #endregion
+
+ #region Token Exchange (RFC 8693)
+
+ ///
+ /// Requests a JWT Authorization Grant (JAG) from an Identity Provider via RFC 8693 Token Exchange.
+ /// Returns the JAG string to be used as a JWT Bearer assertion (RFC 7523) against the MCP authorization server.
+ ///
+ public static async Task RequestJwtAuthorizationGrantAsync(
+ RequestJwtAuthGrantOptions options,
+ HttpClient httpClient,
+ CancellationToken cancellationToken = default)
+ {
+ Throw.IfNull(options);
+ Throw.IfNullOrWhiteSpace(options.TokenEndpoint);
+ Throw.IfNullOrWhiteSpace(options.Audience);
+ Throw.IfNullOrWhiteSpace(options.Resource);
+ Throw.IfNullOrWhiteSpace(options.IdToken);
+ Throw.IfNullOrWhiteSpace(options.ClientId);
+
+ var formData = new Dictionary
+ {
+ ["grant_type"] = GrantTypeTokenExchange,
+ ["requested_token_type"] = TokenTypeIdJag,
+ ["subject_token"] = options.IdToken,
+ ["subject_token_type"] = TokenTypeIdToken,
+ ["audience"] = options.Audience,
+ ["resource"] = options.Resource,
+ ["client_id"] = options.ClientId,
+ };
+
+ if (!string.IsNullOrEmpty(options.ClientSecret))
+ {
+ formData["client_secret"] = options.ClientSecret!;
+ }
+
+ if (!string.IsNullOrEmpty(options.Scope))
+ {
+ formData["scope"] = options.Scope!;
+ }
+
+ using var requestContent = new FormUrlEncodedContent(formData);
+ using var httpRequest = new HttpRequestMessage(HttpMethod.Post, options.TokenEndpoint)
+ {
+ Content = requestContent
+ };
+
+ httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+
+ using var httpResponse = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
+ var responseBody = await httpResponse.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
+
+ if (!httpResponse.IsSuccessStatusCode)
+ {
+ OAuthErrorResponse? errorResponse = null;
+ try
+ {
+ errorResponse = JsonSerializer.Deserialize(responseBody, McpJsonUtilities.JsonContext.Default.OAuthErrorResponse);
+ }
+ catch
+ {
+ // Could not parse error response
+ }
+
+ throw new CrossApplicationAccessException(
+ $"Token exchange failed with status {(int)httpResponse.StatusCode}.",
+ errorResponse?.Error,
+ errorResponse?.ErrorDescription,
+ errorResponse?.ErrorUri);
+ }
+
+ var response = JsonSerializer.Deserialize(responseBody, McpJsonUtilities.JsonContext.Default.JagTokenExchangeResponse);
+
+ if (response is null)
+ {
+ var ex = new CrossApplicationAccessException("Failed to parse token exchange response.");
+ ex.Data["ResponseBody"] = responseBody;
+ throw ex;
+ }
+
+ if (string.IsNullOrEmpty(response.AccessToken))
+ {
+ throw new CrossApplicationAccessException("Token exchange response missing required field: access_token");
+ }
+
+ if (!string.Equals(response.IssuedTokenType, TokenTypeIdJag, StringComparison.Ordinal))
+ {
+ throw new CrossApplicationAccessException(
+ $"Token exchange response issued_token_type must be '{TokenTypeIdJag}', got '{response.IssuedTokenType}'.");
+ }
+
+ if (!string.Equals(response.TokenType, TokenTypeNotApplicable, StringComparison.Ordinal))
+ {
+ throw new CrossApplicationAccessException(
+ $"Token exchange response token_type must be '{TokenTypeNotApplicable}' per RFC 8693 §2.2.1, got '{response.TokenType}'.");
+ }
+
+ return response.AccessToken;
+ }
+
+ #endregion
+
+ #region JWT Bearer Grant (RFC 7523)
+
+ ///
+ /// Exchanges a JWT Authorization Grant (JAG) for an access token at an MCP Server's authorization server
+ /// using the JWT Bearer grant (RFC 7523).
+ ///
+ public static async Task ExchangeJwtBearerGrantAsync(
+ ExchangeJwtBearerGrantOptions options,
+ HttpClient httpClient,
+ CancellationToken cancellationToken = default)
+ {
+ Throw.IfNull(options);
+ Throw.IfNullOrWhiteSpace(options.TokenEndpoint);
+ Throw.IfNullOrWhiteSpace(options.Assertion);
+ Throw.IfNullOrWhiteSpace(options.ClientId);
+
+ var formData = new Dictionary
+ {
+ ["grant_type"] = GrantTypeJwtBearer,
+ ["assertion"] = options.Assertion,
+ ["client_id"] = options.ClientId,
+ };
+
+ if (!string.IsNullOrEmpty(options.ClientSecret))
+ {
+ formData["client_secret"] = options.ClientSecret!;
+ }
+
+ if (!string.IsNullOrEmpty(options.Scope))
+ {
+ formData["scope"] = options.Scope!;
+ }
+
+ using var requestContent = new FormUrlEncodedContent(formData);
+ using var httpRequest = new HttpRequestMessage(HttpMethod.Post, options.TokenEndpoint)
+ {
+ Content = requestContent
+ };
+
+ httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+
+ using var httpResponse = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
+ var responseBody = await httpResponse.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
+
+ if (!httpResponse.IsSuccessStatusCode)
+ {
+ OAuthErrorResponse? errorResponse = null;
+ try
+ {
+ errorResponse = JsonSerializer.Deserialize(responseBody, McpJsonUtilities.JsonContext.Default.OAuthErrorResponse);
+ }
+ catch
+ {
+ // Could not parse error response
+ }
+
+ throw new CrossApplicationAccessException(
+ $"JWT bearer grant failed with status {(int)httpResponse.StatusCode}.",
+ errorResponse?.Error,
+ errorResponse?.ErrorDescription,
+ errorResponse?.ErrorUri);
+ }
+
+ var response = JsonSerializer.Deserialize(responseBody, McpJsonUtilities.JsonContext.Default.JwtBearerAccessTokenResponse);
+
+ if (response is null)
+ {
+ var ex = new CrossApplicationAccessException("Failed to parse JWT bearer grant response.");
+ ex.Data["ResponseBody"] = responseBody;
+ throw ex;
+ }
+
+ if (string.IsNullOrEmpty(response.AccessToken))
+ {
+ throw new CrossApplicationAccessException("JWT bearer grant response missing required field: access_token");
+ }
+
+ if (string.IsNullOrEmpty(response.TokenType))
+ {
+ throw new CrossApplicationAccessException("JWT bearer grant response missing required field: token_type");
+ }
+
+ if (!string.Equals(response.TokenType, "bearer", StringComparison.OrdinalIgnoreCase))
+ {
+ throw new CrossApplicationAccessException(
+ $"JWT bearer grant response token_type must be 'bearer' per RFC 7523, got '{response.TokenType}'.");
+ }
+
+ return new TokenContainer
+ {
+ AccessToken = response.AccessToken,
+ TokenType = response.TokenType,
+ RefreshToken = response.RefreshToken,
+ ExpiresIn = response.ExpiresIn,
+ Scope = response.Scope,
+ ObtainedAt = DateTimeOffset.UtcNow,
+ };
+ }
+
+ #endregion
+
+ #region Helper: Auth Server Metadata Discovery
+
+ private static readonly string[] s_wellKnownPaths = [".well-known/openid-configuration", ".well-known/oauth-authorization-server"];
+
+ ///
+ /// Discovers authorization server metadata from the well-known endpoints.
+ ///
+ internal static async Task DiscoverAuthServerMetadataAsync(
+ Uri issuerUrl,
+ HttpClient httpClient,
+ CancellationToken cancellationToken)
+ {
+ var baseUrl = issuerUrl.ToString();
+ if (!baseUrl.EndsWith("/", StringComparison.Ordinal))
+ {
+ issuerUrl = new Uri($"{baseUrl}/");
+ }
+
+ foreach (var path in s_wellKnownPaths)
+ {
+ try
+ {
+ var wellKnownEndpoint = new Uri(issuerUrl, path);
+ var response = await httpClient.GetAsync(wellKnownEndpoint, cancellationToken).ConfigureAwait(false);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ continue;
+ }
+
+ using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+ var metadata = await JsonSerializer.DeserializeAsync(
+ stream,
+ McpJsonUtilities.JsonContext.Default.AuthorizationServerMetadata,
+ cancellationToken).ConfigureAwait(false);
+
+ if (metadata is not null)
+ {
+ return metadata;
+ }
+ }
+ catch
+ {
+ continue;
+ }
+ }
+
+ throw new CrossApplicationAccessException($"Failed to discover authorization server metadata for: {issuerUrl}");
+ }
+
+ #endregion
+}
diff --git a/src/ModelContextProtocol.Core/Authentication/CrossApplicationAccessContext.cs b/src/ModelContextProtocol.Core/Authentication/CrossApplicationAccessContext.cs
new file mode 100644
index 000000000..4a2b032af
--- /dev/null
+++ b/src/ModelContextProtocol.Core/Authentication/CrossApplicationAccessContext.cs
@@ -0,0 +1,20 @@
+namespace ModelContextProtocol.Authentication;
+
+///
+/// Context provided to the for a Cross-Application Access
+/// authorization flow. Contains the URLs discovered during the OAuth flow needed for the token exchange step.
+///
+public sealed class CrossApplicationAccessContext
+{
+ ///
+ /// Gets the MCP resource server URL (i.e., the resource parameter for token exchange).
+ /// This is the URL of the MCP server being accessed.
+ ///
+ public required Uri ResourceUrl { get; init; }
+
+ ///
+ /// Gets the MCP authorization server URL (i.e., the audience parameter for token exchange).
+ /// This is the URL of the authorization server protecting the MCP resource.
+ ///
+ public required Uri AuthorizationServerUrl { get; init; }
+}
diff --git a/src/ModelContextProtocol.Core/Authentication/CrossApplicationAccessException.cs b/src/ModelContextProtocol.Core/Authentication/CrossApplicationAccessException.cs
new file mode 100644
index 000000000..524e519ea
--- /dev/null
+++ b/src/ModelContextProtocol.Core/Authentication/CrossApplicationAccessException.cs
@@ -0,0 +1,51 @@
+namespace ModelContextProtocol.Authentication;
+
+///
+/// Represents an error that occurred during a Cross-Application Access authorization operation
+/// (token exchange per RFC 8693, and JWT bearer grant per RFC 7523).
+///
+public sealed class CrossApplicationAccessException : Exception
+{
+ ///
+ /// Gets the OAuth error code, if available (e.g., "invalid_request", "invalid_grant").
+ ///
+ public string? ErrorCode { get; }
+
+ ///
+ /// Gets the human-readable error description from the OAuth error response.
+ ///
+ public string? ErrorDescription { get; }
+
+ ///
+ /// Gets the URI identifying a human-readable web page with error information.
+ ///
+ public string? ErrorUri { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The error message.
+ /// The OAuth error code.
+ /// The human-readable error description.
+ /// The error URI.
+ public CrossApplicationAccessException(string message, string? errorCode = null, string? errorDescription = null, string? errorUri = null)
+ : base(FormatMessage(message, errorCode, errorDescription))
+ {
+ ErrorCode = errorCode;
+ ErrorDescription = errorDescription;
+ ErrorUri = errorUri;
+ }
+
+ private static string FormatMessage(string message, string? errorCode, string? errorDescription)
+ {
+ if (!string.IsNullOrEmpty(errorCode))
+ {
+ message = $"{message} Error: {errorCode}";
+ if (!string.IsNullOrEmpty(errorDescription))
+ {
+ message = $"{message} ({errorDescription})";
+ }
+ }
+ return message;
+ }
+}
diff --git a/src/ModelContextProtocol.Core/Authentication/CrossApplicationAccessIdTokenCallback.cs b/src/ModelContextProtocol.Core/Authentication/CrossApplicationAccessIdTokenCallback.cs
new file mode 100644
index 000000000..ab26ea292
--- /dev/null
+++ b/src/ModelContextProtocol.Core/Authentication/CrossApplicationAccessIdTokenCallback.cs
@@ -0,0 +1,17 @@
+namespace ModelContextProtocol.Authentication;
+
+///
+/// Represents a method that returns an OIDC ID token for use in a Cross-Application Access authorization flow.
+///
+///
+/// Context containing the MCP resource and authorization server URLs discovered during the OAuth flow.
+///
+/// The to monitor for cancellation requests.
+///
+/// A task that represents the asynchronous operation. The task result contains the OIDC ID token string
+/// obtained from the enterprise Identity Provider (e.g., via SSO login). The provider will then use this
+/// ID token to perform the RFC 8693 token exchange to obtain a JWT Authorization Grant.
+///
+public delegate Task CrossApplicationAccessIdTokenCallback(
+ CrossApplicationAccessContext context,
+ CancellationToken cancellationToken);
diff --git a/src/ModelContextProtocol.Core/Authentication/CrossApplicationAccessProvider.cs b/src/ModelContextProtocol.Core/Authentication/CrossApplicationAccessProvider.cs
new file mode 100644
index 000000000..dbe58f9e6
--- /dev/null
+++ b/src/ModelContextProtocol.Core/Authentication/CrossApplicationAccessProvider.cs
@@ -0,0 +1,206 @@
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace ModelContextProtocol.Authentication;
+
+///
+/// Provides Cross-Application Access authorization as a standalone, non-interactive provider
+/// that can be used alongside the MCP client's OAuth infrastructure.
+///
+///
+///
+/// This provider implements the full Identity Assertion Authorization Grant flow as specified at
+/// :
+///
+///
+/// -
+/// The is called to obtain an OIDC ID token.
+/// It receives a with the discovered resource and authorization
+/// server URLs.
+///
+/// -
+/// The provider performs the RFC 8693 token exchange at the enterprise Identity Provider
+/// (using the configured IdpTokenEndpoint or discovered from IdpUrl),
+/// exchanging the ID token for a JWT Authorization Grant (JAG).
+///
+/// -
+/// The JAG is then exchanged for an access token at the MCP Server's authorization server
+/// via the RFC 7523 JWT Bearer grant.
+///
+///
+///
+///
+///
+/// var provider = new CrossApplicationAccessProvider(
+/// new CrossApplicationAccessProviderOptions
+/// {
+/// ClientId = "mcp-client-id",
+/// IdpTokenEndpoint = "https://company.okta.com/oauth2/token",
+/// IdpClientId = "idp-client-id",
+/// IdTokenCallback = (context, ct) =>
+/// mySsoClient.GetIdTokenAsync(ct)
+/// },
+/// httpClient: myHttpClient);
+///
+/// var tokens = await provider.GetAccessTokenAsync(
+/// resourceUrl: new Uri("https://mcp-server.example.com"),
+/// authorizationServerUrl: new Uri("https://auth.example.com"),
+/// cancellationToken: ct);
+///
+///
+public sealed class CrossApplicationAccessProvider
+{
+ private readonly CrossApplicationAccessProviderOptions _options;
+ private readonly HttpClient _httpClient;
+ private readonly ILogger _logger;
+
+ private TokenContainer? _cachedTokens;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Configuration for the Cross-Application Access provider.
+ ///
+ /// The HTTP client to use for token exchange requests. The caller is responsible for the lifetime of this instance.
+ ///
+ /// Optional logger factory.
+ /// or is null.
+ /// Required option values are missing.
+ public CrossApplicationAccessProvider(
+ CrossApplicationAccessProviderOptions options,
+ HttpClient httpClient,
+ ILoggerFactory? loggerFactory = null)
+ {
+ Throw.IfNull(options);
+ Throw.IfNull(httpClient);
+
+ Throw.IfNullOrWhiteSpace(options.ClientId);
+ Throw.IfNullOrWhiteSpace(options.IdpClientId);
+
+ if (string.IsNullOrEmpty(options.IdpUrl) && string.IsNullOrEmpty(options.IdpTokenEndpoint))
+ {
+ throw new ArgumentException("Either IdpUrl or IdpTokenEndpoint is required.", $"{nameof(options)}.{nameof(options.IdpUrl)}");
+ }
+
+ if (options.IdTokenCallback is null)
+ {
+ throw new ArgumentNullException($"{nameof(options)}.{nameof(options.IdTokenCallback)}");
+ }
+
+ _options = options;
+ _httpClient = httpClient;
+ _logger = (ILogger?)loggerFactory?.CreateLogger() ?? NullLogger.Instance;
+ }
+
+ ///
+ /// Performs the full Cross-Application Access flow to obtain an access token for the given MCP resource.
+ ///
+ /// The MCP resource server URL.
+ /// The MCP authorization server URL.
+ /// The to monitor for cancellation requests.
+ /// A containing the access token.
+ /// Thrown when any step of the flow fails.
+ public async Task GetAccessTokenAsync(
+ Uri resourceUrl,
+ Uri authorizationServerUrl,
+ CancellationToken cancellationToken = default)
+ {
+ // Return cached token if still valid
+ if (_cachedTokens is not null && !_cachedTokens.IsExpired)
+ {
+ return _cachedTokens;
+ }
+
+ _logger.LogDebug("Starting Cross-Application Access flow for resource {ResourceUrl}", resourceUrl);
+
+ // Step 1: Discover MCP authorization server metadata to find the token endpoint
+ var mcpAuthMetadata = await CrossApplicationAccess.DiscoverAuthServerMetadataAsync(
+ authorizationServerUrl, _httpClient, cancellationToken).ConfigureAwait(false);
+
+ var mcpTokenEndpoint = mcpAuthMetadata.TokenEndpoint?.ToString()
+ ?? throw new CrossApplicationAccessException(
+ $"MCP authorization server metadata at {authorizationServerUrl} missing token_endpoint.");
+
+ // Step 2: Call the ID token callback to get the caller's OIDC ID token
+ var context = new CrossApplicationAccessContext
+ {
+ ResourceUrl = resourceUrl,
+ AuthorizationServerUrl = authorizationServerUrl,
+ };
+
+ _logger.LogDebug("Requesting ID token via callback");
+ var idToken = await _options.IdTokenCallback(context, cancellationToken).ConfigureAwait(false);
+
+ if (string.IsNullOrEmpty(idToken))
+ {
+ throw new CrossApplicationAccessException("ID token callback returned a null or empty token.");
+ }
+
+ // Step 3: RFC 8693 token exchange — ID token → JWT Authorization Grant (JAG) at the enterprise IdP
+ _logger.LogDebug("Performing RFC 8693 token exchange at IdP");
+ var idpTokenEndpoint = await ResolveIdpTokenEndpointAsync(cancellationToken).ConfigureAwait(false);
+
+ var jag = await CrossApplicationAccess.RequestJwtAuthorizationGrantAsync(
+ new RequestJwtAuthGrantOptions
+ {
+ TokenEndpoint = idpTokenEndpoint,
+ Audience = authorizationServerUrl.ToString(),
+ Resource = resourceUrl.ToString(),
+ IdToken = idToken,
+ ClientId = _options.IdpClientId,
+ ClientSecret = _options.IdpClientSecret,
+ Scope = _options.IdpScope,
+ }, _httpClient, cancellationToken).ConfigureAwait(false);
+
+ // Step 4: RFC 7523 JWT bearer grant — JAG → access token at the MCP authorization server
+ _logger.LogDebug("Exchanging JAG for access token at {McpTokenEndpoint}", mcpTokenEndpoint);
+ var tokens = await CrossApplicationAccess.ExchangeJwtBearerGrantAsync(
+ new ExchangeJwtBearerGrantOptions
+ {
+ TokenEndpoint = mcpTokenEndpoint,
+ Assertion = jag,
+ ClientId = _options.ClientId,
+ ClientSecret = _options.ClientSecret,
+ Scope = _options.Scope,
+ }, _httpClient, cancellationToken).ConfigureAwait(false);
+
+ _cachedTokens = tokens;
+ _logger.LogDebug("Cross-Application Access flow completed successfully");
+
+ return tokens;
+ }
+
+ ///
+ /// Clears any cached tokens, forcing a fresh token exchange on the next call to .
+ ///
+ public void InvalidateCache()
+ {
+ _cachedTokens = null;
+ }
+
+ private string? _resolvedIdpTokenEndpoint;
+
+ private async Task ResolveIdpTokenEndpointAsync(CancellationToken cancellationToken)
+ {
+ if (_resolvedIdpTokenEndpoint is not null)
+ {
+ return _resolvedIdpTokenEndpoint;
+ }
+
+ if (!string.IsNullOrEmpty(_options.IdpTokenEndpoint))
+ {
+ _resolvedIdpTokenEndpoint = _options.IdpTokenEndpoint;
+ return _resolvedIdpTokenEndpoint;
+ }
+
+ // Discover from IdpUrl
+ var idpMetadata = await CrossApplicationAccess.DiscoverAuthServerMetadataAsync(
+ new Uri(_options.IdpUrl!), _httpClient, cancellationToken).ConfigureAwait(false);
+
+ _resolvedIdpTokenEndpoint = idpMetadata.TokenEndpoint?.ToString()
+ ?? throw new CrossApplicationAccessException(
+ $"IdP metadata discovery for {_options.IdpUrl} did not return a token_endpoint.");
+
+ return _resolvedIdpTokenEndpoint;
+ }
+}
diff --git a/src/ModelContextProtocol.Core/Authentication/CrossApplicationAccessProviderOptions.cs b/src/ModelContextProtocol.Core/Authentication/CrossApplicationAccessProviderOptions.cs
new file mode 100644
index 000000000..97f34209a
--- /dev/null
+++ b/src/ModelContextProtocol.Core/Authentication/CrossApplicationAccessProviderOptions.cs
@@ -0,0 +1,68 @@
+namespace ModelContextProtocol.Authentication;
+
+///
+/// Configuration options for the .
+///
+public sealed class CrossApplicationAccessProviderOptions
+{
+ ///
+ /// Gets or sets the MCP client ID used for the JWT Bearer grant (RFC 7523) at the MCP authorization server.
+ ///
+ public required string ClientId { get; set; }
+
+ ///
+ /// Gets or sets the MCP client secret used for the JWT Bearer grant at the MCP authorization server.
+ /// Optional; only required if the MCP authorization server requires client authentication.
+ ///
+ public string? ClientSecret { get; set; }
+
+ ///
+ /// Gets or sets the scopes to request from the MCP authorization server (space-separated). Optional.
+ ///
+ public string? Scope { get; set; }
+
+ ///
+ /// Gets or sets the enterprise Identity Provider base URL for OAuth/OIDC metadata discovery.
+ /// Used to discover IdpTokenEndpoint automatically when is not set.
+ /// Either this or must be provided.
+ ///
+ public string? IdpUrl { get; set; }
+
+ ///
+ /// Gets or sets the enterprise Identity Provider token endpoint URL for RFC 8693 token exchange.
+ /// When provided, skips IdP metadata discovery. Either this or must be provided.
+ ///
+ public string? IdpTokenEndpoint { get; set; }
+
+ ///
+ /// Gets or sets the client ID for authentication with the enterprise Identity Provider (RFC 8693 token exchange).
+ ///
+ public required string IdpClientId { get; set; }
+
+ ///
+ /// Gets or sets the client secret for authentication with the enterprise Identity Provider. Optional.
+ ///
+ public string? IdpClientSecret { get; set; }
+
+ ///
+ /// Gets or sets the scopes to request from the enterprise Identity Provider (space-separated). Optional.
+ ///
+ public string? IdpScope { get; set; }
+
+ ///
+ /// Gets or sets the callback that supplies the OIDC ID token for the Cross-Application Access flow.
+ ///
+ ///
+ ///
+ /// This callback is invoked after the MCP resource and authorization server URLs have been discovered.
+ /// It receives a with these URLs and should return the
+ /// OIDC ID token string obtained from the enterprise Identity Provider (e.g., from an SSO login session).
+ ///
+ ///
+ /// The provider will use the returned ID token to internally perform the RFC 8693 token exchange at the
+ /// configured IdP, obtaining a JWT Authorization Grant, which is then exchanged for an access token at
+ /// the MCP authorization server via RFC 7523.
+ ///
+ ///
+ public required CrossApplicationAccessIdTokenCallback IdTokenCallback { get; set; }
+}
diff --git a/src/ModelContextProtocol.Core/Authentication/ExchangeJwtBearerGrantOptions.cs b/src/ModelContextProtocol.Core/Authentication/ExchangeJwtBearerGrantOptions.cs
new file mode 100644
index 000000000..9dd440bb8
--- /dev/null
+++ b/src/ModelContextProtocol.Core/Authentication/ExchangeJwtBearerGrantOptions.cs
@@ -0,0 +1,32 @@
+namespace ModelContextProtocol.Authentication;
+
+///
+/// Options for exchanging a JWT Authorization Grant for an access token via RFC 7523.
+///
+internal sealed class ExchangeJwtBearerGrantOptions
+{
+ ///
+ /// Gets or sets the MCP Server's authorization server token endpoint URL.
+ ///
+ public required string TokenEndpoint { get; set; }
+
+ ///
+ /// Gets or sets the JWT Authorization Grant (JAG) assertion obtained from token exchange.
+ ///
+ public required string Assertion { get; set; }
+
+ ///
+ /// Gets or sets the client ID for authentication with the MCP authorization server.
+ ///
+ public required string ClientId { get; set; }
+
+ ///
+ /// Gets or sets the client secret for authentication with the MCP authorization server. Optional.
+ ///
+ public string? ClientSecret { get; set; }
+
+ ///
+ /// Gets or sets the scopes to request (space-separated). Optional.
+ ///
+ public string? Scope { get; set; }
+}
diff --git a/src/ModelContextProtocol.Core/Authentication/JagTokenExchangeResponse.cs b/src/ModelContextProtocol.Core/Authentication/JagTokenExchangeResponse.cs
new file mode 100644
index 000000000..318af3334
--- /dev/null
+++ b/src/ModelContextProtocol.Core/Authentication/JagTokenExchangeResponse.cs
@@ -0,0 +1,40 @@
+namespace ModelContextProtocol.Authentication;
+
+///
+/// Represents the response from an RFC 8693 Token Exchange for the JAG flow.
+/// Contains the JWT Authorization Grant in the field.
+///
+internal sealed class JagTokenExchangeResponse
+{
+ ///
+ /// Gets or sets the issued JAG. Despite the name "access_token" (required by RFC 8693),
+ /// this contains a JAG JWT, not an OAuth access token.
+ ///
+ [System.Text.Json.Serialization.JsonPropertyName("access_token")]
+ public string AccessToken { get; set; } = null!;
+
+ ///
+ /// Gets or sets the type of the security token issued.
+ /// This MUST be .
+ ///
+ [System.Text.Json.Serialization.JsonPropertyName("issued_token_type")]
+ public string IssuedTokenType { get; set; } = null!;
+
+ ///
+ /// Gets or sets the token type. This MUST be "N_A" per RFC 8693 §2.2.1.
+ ///
+ [System.Text.Json.Serialization.JsonPropertyName("token_type")]
+ public string TokenType { get; set; } = null!;
+
+ ///
+ /// Gets or sets the scope of the issued token, if different from the request.
+ ///
+ [System.Text.Json.Serialization.JsonPropertyName("scope")]
+ public string? Scope { get; set; }
+
+ ///
+ /// Gets or sets the lifetime in seconds of the issued token.
+ ///
+ [System.Text.Json.Serialization.JsonPropertyName("expires_in")]
+ public int? ExpiresIn { get; set; }
+}
diff --git a/src/ModelContextProtocol.Core/Authentication/JwtBearerAccessTokenResponse.cs b/src/ModelContextProtocol.Core/Authentication/JwtBearerAccessTokenResponse.cs
new file mode 100644
index 000000000..9a0a4004e
--- /dev/null
+++ b/src/ModelContextProtocol.Core/Authentication/JwtBearerAccessTokenResponse.cs
@@ -0,0 +1,37 @@
+namespace ModelContextProtocol.Authentication;
+
+///
+/// Represents the response from a JWT Bearer grant (RFC 7523) access token request.
+///
+internal sealed class JwtBearerAccessTokenResponse
+{
+ ///
+ /// Gets or sets the OAuth access token.
+ ///
+ [System.Text.Json.Serialization.JsonPropertyName("access_token")]
+ public string AccessToken { get; set; } = null!;
+
+ ///
+ /// Gets or sets the token type. This should be "Bearer".
+ ///
+ [System.Text.Json.Serialization.JsonPropertyName("token_type")]
+ public string TokenType { get; set; } = null!;
+
+ ///
+ /// Gets or sets the lifetime in seconds of the access token.
+ ///
+ [System.Text.Json.Serialization.JsonPropertyName("expires_in")]
+ public int? ExpiresIn { get; set; }
+
+ ///
+ /// Gets or sets the refresh token.
+ ///
+ [System.Text.Json.Serialization.JsonPropertyName("refresh_token")]
+ public string? RefreshToken { get; set; }
+
+ ///
+ /// Gets or sets the scope of the access token.
+ ///
+ [System.Text.Json.Serialization.JsonPropertyName("scope")]
+ public string? Scope { get; set; }
+}
diff --git a/src/ModelContextProtocol.Core/Authentication/OAuthErrorResponse.cs b/src/ModelContextProtocol.Core/Authentication/OAuthErrorResponse.cs
new file mode 100644
index 000000000..a8822fa32
--- /dev/null
+++ b/src/ModelContextProtocol.Core/Authentication/OAuthErrorResponse.cs
@@ -0,0 +1,26 @@
+namespace ModelContextProtocol.Authentication;
+
+///
+/// Represents an OAuth error response per RFC 6749 Section 5.2.
+/// Used for both token exchange and JWT bearer grant error responses.
+///
+internal sealed class OAuthErrorResponse
+{
+ ///
+ /// Gets or sets the error code.
+ ///
+ [System.Text.Json.Serialization.JsonPropertyName("error")]
+ public string? Error { get; set; }
+
+ ///
+ /// Gets or sets the human-readable error description.
+ ///
+ [System.Text.Json.Serialization.JsonPropertyName("error_description")]
+ public string? ErrorDescription { get; set; }
+
+ ///
+ /// Gets or sets the URI identifying a human-readable web page with error information.
+ ///
+ [System.Text.Json.Serialization.JsonPropertyName("error_uri")]
+ public string? ErrorUri { get; set; }
+}
diff --git a/src/ModelContextProtocol.Core/Authentication/RequestJwtAuthGrantOptions.cs b/src/ModelContextProtocol.Core/Authentication/RequestJwtAuthGrantOptions.cs
new file mode 100644
index 000000000..7e83b198d
--- /dev/null
+++ b/src/ModelContextProtocol.Core/Authentication/RequestJwtAuthGrantOptions.cs
@@ -0,0 +1,42 @@
+namespace ModelContextProtocol.Authentication;
+
+///
+/// Options for requesting a JWT Authorization Grant from an Identity Provider via RFC 8693 Token Exchange.
+///
+internal sealed class RequestJwtAuthGrantOptions
+{
+ ///
+ /// Gets or sets the IDP's token endpoint URL.
+ ///
+ public required string TokenEndpoint { get; set; }
+
+ ///
+ /// Gets or sets the MCP authorization server URL (used as the audience parameter).
+ ///
+ public required string Audience { get; set; }
+
+ ///
+ /// Gets or sets the MCP resource server URL (used as the resource parameter).
+ ///
+ public required string Resource { get; set; }
+
+ ///
+ /// Gets or sets the OIDC ID token to exchange.
+ ///
+ public required string IdToken { get; set; }
+
+ ///
+ /// Gets or sets the client ID for authentication with the IDP.
+ ///
+ public required string ClientId { get; set; }
+
+ ///
+ /// Gets or sets the client secret for authentication with the IDP. Optional.
+ ///
+ public string? ClientSecret { get; set; }
+
+ ///
+ /// Gets or sets the scopes to request (space-separated). Optional.
+ ///
+ public string? Scope { get; set; }
+}
diff --git a/src/ModelContextProtocol.Core/McpJsonUtilities.cs b/src/ModelContextProtocol.Core/McpJsonUtilities.cs
index abb6d29df..70eb30d0d 100644
--- a/src/ModelContextProtocol.Core/McpJsonUtilities.cs
+++ b/src/ModelContextProtocol.Core/McpJsonUtilities.cs
@@ -187,6 +187,12 @@ internal static bool IsValidMcpToolSchema(JsonElement element)
[JsonSerializable(typeof(DynamicClientRegistrationRequest))]
[JsonSerializable(typeof(DynamicClientRegistrationResponse))]
+ // For Enterprise Managed Authorization flow as specified at
+ // https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/enterprise-managed-authorization.mdx
+ [JsonSerializable(typeof(JagTokenExchangeResponse))]
+ [JsonSerializable(typeof(JwtBearerAccessTokenResponse))]
+ [JsonSerializable(typeof(OAuthErrorResponse))]
+
// Primitive types for use in consuming AIFunctions
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(byte))]
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/CrossApplicationAccessIntegrationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/CrossApplicationAccessIntegrationTests.cs
new file mode 100644
index 000000000..43f97f8c8
--- /dev/null
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/CrossApplicationAccessIntegrationTests.cs
@@ -0,0 +1,167 @@
+using Microsoft.Extensions.DependencyInjection;
+using ModelContextProtocol.Authentication;
+using ModelContextProtocol.Client;
+using System.Net.Http.Headers;
+
+namespace ModelContextProtocol.AspNetCore.Tests.OAuth;
+
+///
+/// Integration tests for Cross-Application Access authorization using the in-memory
+/// test OAuth server as a stand-in for both the enterprise Identity Provider (IdP) and
+/// the MCP Authorization Server (AS).
+///
+/// Flow exercised:
+/// 1. discovers the MCP AS
+/// metadata and calls the ID token callback.
+/// 2. The provider performs RFC 8693 token exchange at /idp/token on the test OAuth server
+/// (ID token → JAG).
+/// 3. The provider exchanges the JAG for an access token at /token
+/// (RFC 7523 JWT-bearer grant: JAG → access token).
+/// 4. The access token is passed to the MCP client transport and used to authenticate
+/// against the protected MCP server.
+///
+public class CrossApplicationAccessIntegrationTests : OAuthTestBase
+{
+ public CrossApplicationAccessIntegrationTests(ITestOutputHelper outputHelper)
+ : base(outputHelper)
+ {
+ }
+
+ [Fact]
+ public async Task CanAuthenticate_WithCrossApplicationAccessProvider()
+ {
+ // Enable Enterprise Managed Authorization endpoints on the test OAuth server.
+ TestOAuthServer.EnterpriseSupportEnabled = true;
+
+ await using var app = await StartMcpServerAsync();
+
+ // Simulate the enterprise ID token that would normally come from the SSO login step.
+ const string simulatedIdToken = "test-enterprise-sso-id-token";
+
+ // Create the provider with IdP config folded into options.
+ // The ID token callback just returns the SSO ID token; the provider performs
+ // RFC 8693 (ID token → JAG) and RFC 7523 (JAG → access token) internally.
+ var provider = new CrossApplicationAccessProvider(
+ new CrossApplicationAccessProviderOptions
+ {
+ ClientId = "enterprise-mcp-client",
+ ClientSecret = "enterprise-mcp-secret",
+ IdpTokenEndpoint = $"{OAuthServerUrl}/idp/token",
+ IdpClientId = "enterprise-idp-client",
+ IdpClientSecret = "enterprise-idp-secret",
+ IdTokenCallback = (_, ct) => Task.FromResult(simulatedIdToken),
+ },
+ httpClient: HttpClient);
+
+ // Run the full Cross-Application Access flow: discover AS → get JAG → exchange for access token.
+ var tokens = await provider.GetAccessTokenAsync(
+ resourceUrl: new Uri(McpServerUrl),
+ authorizationServerUrl: new Uri(OAuthServerUrl),
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.NotNull(tokens.AccessToken);
+ Assert.False(string.IsNullOrEmpty(tokens.AccessToken));
+ Assert.Equal("bearer", tokens.TokenType, ignoreCase: true);
+
+ // Wire the obtained access token into an HTTP client that shares the same
+ // in-memory Kestrel transport as the rest of the test fixture.
+ var mcpHttpClient = new HttpClient(SocketsHttpHandler, disposeHandler: false);
+ ConfigureHttpClient(mcpHttpClient);
+ mcpHttpClient.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", tokens.AccessToken);
+
+ // Connect the MCP client using the enterprise access token — no interactive OAuth flow.
+ await using var transport = new HttpClientTransport(
+ new HttpClientTransportOptions { Endpoint = new Uri(McpServerUrl) },
+ mcpHttpClient,
+ LoggerFactory);
+
+ await using var client = await McpClient.CreateAsync(
+ transport,
+ loggerFactory: LoggerFactory,
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ // If we get here the MCP server accepted the enterprise access token.
+ Assert.NotNull(client);
+ }
+
+ [Fact]
+ public async Task CrossApplicationAccessProvider_ReturnsCachedToken_OnSecondCall()
+ {
+ TestOAuthServer.EnterpriseSupportEnabled = true;
+
+ await using var _ = await StartMcpServerAsync();
+
+ var idTokenCallCount = 0;
+
+ var provider = new CrossApplicationAccessProvider(
+ new CrossApplicationAccessProviderOptions
+ {
+ ClientId = "enterprise-mcp-client",
+ ClientSecret = "enterprise-mcp-secret",
+ IdpTokenEndpoint = $"{OAuthServerUrl}/idp/token",
+ IdpClientId = "enterprise-idp-client",
+ IdpClientSecret = "enterprise-idp-secret",
+ IdTokenCallback = (_, ct) =>
+ {
+ idTokenCallCount++;
+ return Task.FromResult("test-sso-token");
+ },
+ },
+ httpClient: HttpClient);
+
+ var tokens1 = await provider.GetAccessTokenAsync(
+ new Uri(McpServerUrl), new Uri(OAuthServerUrl),
+ TestContext.Current.CancellationToken);
+
+ var tokens2 = await provider.GetAccessTokenAsync(
+ new Uri(McpServerUrl), new Uri(OAuthServerUrl),
+ TestContext.Current.CancellationToken);
+
+ // The ID token callback (and therefore the IdP round-trip) should only fire once.
+ Assert.Equal(1, idTokenCallCount);
+ Assert.Equal(tokens1.AccessToken, tokens2.AccessToken);
+ }
+
+ [Fact]
+ public async Task CrossApplicationAccessProvider_FetchesFreshToken_AfterInvalidateCache()
+ {
+ TestOAuthServer.EnterpriseSupportEnabled = true;
+
+ await using var _ = await StartMcpServerAsync();
+
+ var idTokenCallCount2 = 0;
+
+ var provider2 = new CrossApplicationAccessProvider(
+ new CrossApplicationAccessProviderOptions
+ {
+ ClientId = "enterprise-mcp-client",
+ ClientSecret = "enterprise-mcp-secret",
+ IdpTokenEndpoint = $"{OAuthServerUrl}/idp/token",
+ IdpClientId = "enterprise-idp-client",
+ IdpClientSecret = "enterprise-idp-secret",
+ IdTokenCallback = (_, ct) =>
+ {
+ idTokenCallCount2++;
+ return Task.FromResult("test-sso-token");
+ },
+ },
+ httpClient: HttpClient);
+
+ var tokens1 = await provider2.GetAccessTokenAsync(
+ new Uri(McpServerUrl), new Uri(OAuthServerUrl),
+ TestContext.Current.CancellationToken);
+
+ // Invalidate the cache to force a full re-exchange.
+ provider2.InvalidateCache();
+
+ var tokens2 = await provider2.GetAccessTokenAsync(
+ new Uri(McpServerUrl), new Uri(OAuthServerUrl),
+ TestContext.Current.CancellationToken);
+
+ // The IdP should have been called twice — once for each GetAccessTokenAsync after invalidation.
+ Assert.Equal(2, idTokenCallCount2);
+ // The tokens may or may not be identical depending on timing, but the flow ran again.
+ Assert.NotNull(tokens2.AccessToken);
+ }
+}
diff --git a/tests/ModelContextProtocol.TestOAuthServer/JagTokenExchangeResponse.cs b/tests/ModelContextProtocol.TestOAuthServer/JagTokenExchangeResponse.cs
new file mode 100644
index 000000000..cae8a943d
--- /dev/null
+++ b/tests/ModelContextProtocol.TestOAuthServer/JagTokenExchangeResponse.cs
@@ -0,0 +1,38 @@
+using System.Text.Json.Serialization;
+
+namespace ModelContextProtocol.TestOAuthServer;
+
+///
+/// Represents the token exchange response for the Identity Assertion JWT Authorization Grant (ID-JAG)
+/// per RFC 8693 / SEP-990.
+///
+internal sealed class JagTokenExchangeResponse
+{
+ ///
+ /// Gets or sets the issued JWT Authorization Grant (JAG).
+ /// Despite the field name "access_token" (required by RFC 8693), this contains a JAG JWT,
+ /// not an OAuth access token.
+ ///
+ [JsonPropertyName("access_token")]
+ public required string AccessToken { get; init; }
+
+ ///
+ /// Gets or sets the type of security token issued.
+ /// For SEP-990, this MUST be "urn:ietf:params:oauth:token-type:id-jag".
+ ///
+ [JsonPropertyName("issued_token_type")]
+ public required string IssuedTokenType { get; init; }
+
+ ///
+ /// Gets or sets the token type.
+ /// For SEP-990, this MUST be "N_A" per RFC 8693 §2.2.1 because the JAG is not an access token.
+ ///
+ [JsonPropertyName("token_type")]
+ public required string TokenType { get; init; }
+
+ ///
+ /// Gets or sets the lifetime in seconds of the issued JAG.
+ ///
+ [JsonPropertyName("expires_in")]
+ public int? ExpiresIn { get; init; }
+}
diff --git a/tests/ModelContextProtocol.TestOAuthServer/OAuthJsonContext.cs b/tests/ModelContextProtocol.TestOAuthServer/OAuthJsonContext.cs
index 6caaaea01..e8c98275a 100644
--- a/tests/ModelContextProtocol.TestOAuthServer/OAuthJsonContext.cs
+++ b/tests/ModelContextProtocol.TestOAuthServer/OAuthJsonContext.cs
@@ -5,6 +5,7 @@ namespace ModelContextProtocol.TestOAuthServer;
[JsonSerializable(typeof(OAuthServerMetadata))]
[JsonSerializable(typeof(AuthorizationServerMetadata))]
[JsonSerializable(typeof(TokenResponse))]
+[JsonSerializable(typeof(JagTokenExchangeResponse))]
[JsonSerializable(typeof(JsonWebKeySet))]
[JsonSerializable(typeof(JsonWebKey))]
[JsonSerializable(typeof(TokenIntrospectionResponse))]
diff --git a/tests/ModelContextProtocol.TestOAuthServer/Program.cs b/tests/ModelContextProtocol.TestOAuthServer/Program.cs
index a65c5e4ab..0d35a742f 100644
--- a/tests/ModelContextProtocol.TestOAuthServer/Program.cs
+++ b/tests/ModelContextProtocol.TestOAuthServer/Program.cs
@@ -57,6 +57,18 @@ public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactor
// Track if we've already issued an already-expired token for the CanAuthenticate_WithTokenRefresh test which uses the test-refresh-client registration.
public bool HasRefreshedToken { get; set; }
+ ///
+ /// Gets or sets a value indicating whether the server supports the Enterprise Managed
+ /// Authorization (SEP-990) flow, including the IdP token-exchange endpoint and the
+ /// JWT-bearer grant type at the token endpoint.
+ ///
+ ///
+ /// When true, the server registers enterprise test clients and activates the
+ /// /idp/token endpoint (RFC 8693 token exchange) and the
+ /// urn:ietf:params:oauth:grant-type:jwt-bearer grant type (RFC 7523).
+ ///
+ public bool EnterpriseSupportEnabled { get; set; }
+
///
/// Gets or sets a value indicating whether the authorization server
/// advertises support for client ID metadata documents in its discovery
@@ -168,6 +180,25 @@ public async Task RunServerAsync(string[]? args = null, CancellationToken cancel
RedirectUris = ["http://localhost:1179/callback"],
};
+ // Enterprise Auth (SEP-990) clients.
+ // The IdP client is used to authenticate calls to /idp/token (token exchange).
+ // The MCP client is used to authenticate calls to /token (jwt-bearer grant).
+ // Neither needs redirect URIs because neither uses the authorization code flow.
+ _clients["enterprise-idp-client"] = new ClientInfo
+ {
+ ClientId = "enterprise-idp-client",
+ ClientSecret = "enterprise-idp-secret",
+ RequiresClientSecret = true,
+ RedirectUris = [],
+ };
+ _clients["enterprise-mcp-client"] = new ClientInfo
+ {
+ ClientId = "enterprise-mcp-client",
+ ClientSecret = "enterprise-mcp-secret",
+ RequiresClientSecret = true,
+ RedirectUris = [],
+ };
+
// The MCP spec tells the client to use /.well-known/oauth-authorization-server but AddJwtBearer looks for
// /.well-known/openid-configuration by default.
//
@@ -360,10 +391,18 @@ IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null)
type: "https://tools.ietf.org/html/rfc6749#section-5.2");
}
+ // Read grant type early so we can skip resource validation for grant types that
+ // don't use the resource parameter (e.g. jwt-bearer where the resource is embedded
+ // inside the JWT assertion itself).
+ var grant_type = form["grant_type"].ToString();
+
// Validate resource in accordance with RFC 8707.
// When ExpectResource is false, the resource parameter must be absent (legacy mode).
+ // RFC 7523 JWT-bearer assertions carry the target resource inside the JWT itself,
+ // so we skip the form-level resource check for that grant type.
var resource = form["resource"].ToString();
- if (ExpectResource ? (string.IsNullOrEmpty(resource) || !ValidResources.Contains(resource)) : !string.IsNullOrEmpty(resource))
+ if (grant_type != "urn:ietf:params:oauth:grant-type:jwt-bearer" &&
+ (ExpectResource ? (string.IsNullOrEmpty(resource) || !ValidResources.Contains(resource)) : !string.IsNullOrEmpty(resource)))
{
return Results.BadRequest(new OAuthErrorResponse
{
@@ -372,7 +411,6 @@ IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null)
});
}
- var grant_type = form["grant_type"].ToString();
if (grant_type == "authorization_code")
{
var code = form["code"].ToString();
@@ -449,6 +487,45 @@ IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null)
HasRefreshedToken = true;
return Results.Ok(response);
}
+ else if (grant_type == "urn:ietf:params:oauth:grant-type:jwt-bearer")
+ {
+ if (!EnterpriseSupportEnabled)
+ {
+ return Results.BadRequest(new OAuthErrorResponse
+ {
+ Error = "unsupported_grant_type",
+ ErrorDescription = "JWT bearer grant is not enabled on this server."
+ });
+ }
+
+ var assertion = form["assertion"].ToString();
+ if (string.IsNullOrEmpty(assertion))
+ {
+ return Results.BadRequest(new OAuthErrorResponse
+ {
+ Error = "invalid_request",
+ ErrorDescription = "assertion is required for jwt-bearer grant"
+ });
+ }
+
+ // Extract the target resource from the JAG payload (set during /idp/token).
+ // Fall back to ValidResources[0] so the token is still usable in tests even
+ // if the resource claim is absent.
+ var jagResource = ExtractJwtClaim(assertion, "resource");
+ if (string.IsNullOrEmpty(jagResource) || !ValidResources.Contains(jagResource))
+ {
+ jagResource = ValidResources.Length > 0 ? ValidResources[0] : null;
+ }
+
+ var resourceUri = jagResource is not null ? new Uri(jagResource) : null;
+ var scope = form["scope"].ToString();
+ var scopes = string.IsNullOrEmpty(scope)
+ ? ["mcp:tools"]
+ : scope.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList();
+
+ var response = GenerateJwtTokenResponse(client.ClientId, scopes, resourceUri);
+ return Results.Ok(response);
+ }
else
{
return Results.BadRequest(new OAuthErrorResponse
@@ -459,6 +536,77 @@ IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null)
}
});
+ // IdP token-exchange endpoint (RFC 8693) for Enterprise Managed Authorization (SEP-990).
+ // Exchanges an enterprise ID token (from SSO) for a JWT Authorization Grant (JAG)
+ // that can subsequently be used at the /token endpoint via the jwt-bearer grant.
+ app.MapPost("/idp/token", async (HttpContext context) =>
+ {
+ if (!EnterpriseSupportEnabled)
+ {
+ return Results.NotFound();
+ }
+
+ var form = await context.Request.ReadFormAsync();
+
+ // Authenticate the IdP client.
+ var client = AuthenticateClient(context, form);
+ if (client == null)
+ {
+ context.Response.StatusCode = 401;
+ return Results.Problem(
+ statusCode: 401,
+ title: "Unauthorized",
+ detail: "Invalid client credentials",
+ type: "https://tools.ietf.org/html/rfc6749#section-5.2");
+ }
+
+ var grantType = form["grant_type"].ToString();
+ if (grantType != "urn:ietf:params:oauth:grant-type:token-exchange")
+ {
+ return Results.BadRequest(new OAuthErrorResponse
+ {
+ Error = "unsupported_grant_type",
+ ErrorDescription = "Only urn:ietf:params:oauth:grant-type:token-exchange is supported on this endpoint."
+ });
+ }
+
+ var subjectToken = form["subject_token"].ToString();
+ if (string.IsNullOrEmpty(subjectToken))
+ {
+ return Results.BadRequest(new OAuthErrorResponse
+ {
+ Error = "invalid_request",
+ ErrorDescription = "subject_token is required."
+ });
+ }
+
+ var requestedTokenType = form["requested_token_type"].ToString();
+ if (requestedTokenType != "urn:ietf:params:oauth:token-type:id-jag")
+ {
+ return Results.BadRequest(new OAuthErrorResponse
+ {
+ Error = "invalid_request",
+ ErrorDescription = "requested_token_type must be urn:ietf:params:oauth:token-type:id-jag."
+ });
+ }
+
+ var audience = form["audience"].ToString();
+ var resourceParam = form["resource"].ToString();
+
+ // Generate a JAG JWT signed with the server's RSA key.
+ // The JAG encodes the intended audience (MCP AS) and resource (MCP server) so
+ // the /token endpoint can later issue a correctly-scoped access token.
+ var jag = GenerateJagJwt(audience, resourceParam);
+
+ return Results.Ok(new JagTokenExchangeResponse
+ {
+ AccessToken = jag,
+ IssuedTokenType = "urn:ietf:params:oauth:token-type:id-jag",
+ TokenType = "N_A",
+ ExpiresIn = 300,
+ });
+ });
+
// Introspection endpoint
app.MapPost("/introspect", async (HttpContext context) =>
{
@@ -687,6 +835,70 @@ private TokenResponse GenerateJwtTokenResponse(string clientId, List sco
};
}
+ ///
+ /// Generates a JWT Authorization Grant (JAG) signed with the server's RSA key.
+ /// The JAG encodes the target audience (MCP AS URL) and the resource (MCP server URL).
+ ///
+ private string GenerateJagJwt(string audience, string resource)
+ {
+ var expiresIn = TimeSpan.FromMinutes(5);
+ var issuedAt = DateTimeOffset.UtcNow;
+ var expiresAt = issuedAt.Add(expiresIn);
+
+ var header = new Dictionary
+ {
+ { "alg", "RS256" },
+ { "typ", "JWT" },
+ { "kid", _keyId },
+ };
+
+ var payload = new Dictionary
+ {
+ { "iss", _url },
+ { "sub", "enterprise-user" },
+ { "aud", audience },
+ { "resource", resource }, // carried through so /token can issue the right audience
+ { "jti", Guid.NewGuid().ToString() },
+ { "iat", issuedAt.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture) },
+ { "exp", expiresAt.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture) },
+ };
+
+ var headerJson = System.Text.Json.JsonSerializer.Serialize(header, OAuthJsonContext.Default.DictionaryStringString);
+ var payloadJson = System.Text.Json.JsonSerializer.Serialize(payload, OAuthJsonContext.Default.DictionaryStringString);
+
+ var headerBase64 = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(headerJson));
+ var payloadBase64 = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(payloadJson));
+
+ var dataToSign = $"{headerBase64}.{payloadBase64}";
+ var signature = _rsa.SignData(Encoding.UTF8.GetBytes(dataToSign), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+
+ return $"{headerBase64}.{payloadBase64}.{WebEncoders.Base64UrlEncode(signature)}";
+ }
+
+ ///
+ /// Decodes a JWT payload (without signature verification) and returns the value of
+ /// , or null if the claim is absent or the JWT is malformed.
+ ///
+ private static string? ExtractJwtClaim(string jwt, string claimName)
+ {
+ var parts = jwt.Split('.');
+ if (parts.Length < 2)
+ {
+ return null;
+ }
+
+ try
+ {
+ var payloadJson = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(parts[1]));
+ var payload = System.Text.Json.JsonSerializer.Deserialize(payloadJson, OAuthJsonContext.Default.DictionaryStringString);
+ return payload?.TryGetValue(claimName, out var value) == true ? value : null;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
///
/// Generates a random token for authorization code or refresh token.
///
diff --git a/tests/ModelContextProtocol.Tests/CrossApplicationAccessTests.cs b/tests/ModelContextProtocol.Tests/CrossApplicationAccessTests.cs
new file mode 100644
index 000000000..fa0dd09fe
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/CrossApplicationAccessTests.cs
@@ -0,0 +1,389 @@
+using System.Net;
+using System.Text.Json.Nodes;
+using ModelContextProtocol.Authentication;
+
+namespace ModelContextProtocol.Tests;
+
+public sealed class CrossApplicationAccessTests : IDisposable
+{
+ private readonly MockHttpMessageHandler _mockHandler;
+ private readonly HttpClient _httpClient;
+
+ public CrossApplicationAccessTests()
+ {
+ _mockHandler = new MockHttpMessageHandler();
+ _httpClient = new HttpClient(_mockHandler);
+ }
+
+ public void Dispose()
+ {
+ _httpClient.Dispose();
+ _mockHandler.Dispose();
+ }
+
+ #region CrossApplicationAccessProvider Tests
+
+ [Fact]
+ public async Task CrossApplicationAccessProvider_FullFlow_ReturnsAccessToken()
+ {
+ _mockHandler.Handler = request =>
+ {
+ var url = request.RequestUri!.ToString();
+
+ if (url.Contains(".well-known/openid-configuration"))
+ {
+ return JsonResponse(HttpStatusCode.OK, new JsonObject
+ {
+ ["issuer"] = "https://auth.mcp-server.example.com",
+ ["authorization_endpoint"] = "https://auth.mcp-server.example.com/authorize",
+ ["token_endpoint"] = "https://auth.mcp-server.example.com/token",
+ });
+ }
+
+ if (url.Contains("idp.example.com/token"))
+ {
+ return JsonResponse(HttpStatusCode.OK, new JsonObject
+ {
+ ["access_token"] = "mock-jag-assertion",
+ ["issued_token_type"] = "urn:ietf:params:oauth:token-type:id-jag",
+ ["token_type"] = "N_A",
+ });
+ }
+
+ if (url.Contains("auth.mcp-server.example.com/token"))
+ {
+ return JsonResponse(HttpStatusCode.OK, new JsonObject
+ {
+ ["access_token"] = "final-access-token",
+ ["token_type"] = "Bearer",
+ ["expires_in"] = 3600,
+ });
+ }
+
+ return new HttpResponseMessage(HttpStatusCode.NotFound);
+ };
+
+ var provider = new CrossApplicationAccessProvider(
+ new CrossApplicationAccessProviderOptions
+ {
+ ClientId = "mcp-client-id",
+ IdpTokenEndpoint = "https://idp.example.com/token",
+ IdpClientId = "idp-client-id",
+ IdTokenCallback = (context, ct) =>
+ {
+ Assert.Equal(new Uri("https://mcp-server.example.com"), context.ResourceUrl);
+ Assert.Equal(new Uri("https://auth.mcp-server.example.com"), context.AuthorizationServerUrl);
+ return Task.FromResult("mock-id-token");
+ },
+ },
+ _httpClient);
+
+ var tokens = await provider.GetAccessTokenAsync(
+ resourceUrl: new Uri("https://mcp-server.example.com"),
+ authorizationServerUrl: new Uri("https://auth.mcp-server.example.com"),
+ TestContext.Current.CancellationToken);
+
+ Assert.Equal("final-access-token", tokens.AccessToken);
+ Assert.Equal("Bearer", tokens.TokenType);
+ Assert.Equal(3600, tokens.ExpiresIn);
+ }
+
+ [Fact]
+ public async Task CrossApplicationAccessProvider_CachesTokens()
+ {
+ var mcpTokenCallCount = 0;
+ _mockHandler.Handler = request =>
+ {
+ var url = request.RequestUri!.ToString();
+ if (url.Contains(".well-known"))
+ {
+ return JsonResponse(HttpStatusCode.OK, new JsonObject
+ {
+ ["authorization_endpoint"] = "https://auth.example.com/authorize",
+ ["token_endpoint"] = "https://auth.example.com/token",
+ });
+ }
+
+ if (url.Contains("idp.example.com"))
+ {
+ return JsonResponse(HttpStatusCode.OK, new JsonObject
+ {
+ ["access_token"] = "mock-jag",
+ ["issued_token_type"] = "urn:ietf:params:oauth:token-type:id-jag",
+ ["token_type"] = "N_A",
+ });
+ }
+
+ mcpTokenCallCount++;
+ return JsonResponse(HttpStatusCode.OK, new JsonObject
+ {
+ ["access_token"] = "cached-token",
+ ["token_type"] = "Bearer",
+ ["expires_in"] = 3600,
+ });
+ };
+
+ var idTokenCallCount = 0;
+ var provider = new CrossApplicationAccessProvider(
+ new CrossApplicationAccessProviderOptions
+ {
+ ClientId = "client-id",
+ IdpTokenEndpoint = "https://idp.example.com/token",
+ IdpClientId = "idp-client-id",
+ IdTokenCallback = (_, _) =>
+ {
+ idTokenCallCount++;
+ return Task.FromResult("mock-id-token");
+ },
+ },
+ _httpClient);
+
+ var ct = TestContext.Current.CancellationToken;
+
+ var firstTokens = await provider.GetAccessTokenAsync(
+ new Uri("https://resource.example.com"),
+ new Uri("https://auth.example.com"), ct);
+
+ var secondTokens = await provider.GetAccessTokenAsync(
+ new Uri("https://resource.example.com"),
+ new Uri("https://auth.example.com"), ct);
+
+ Assert.Same(firstTokens, secondTokens);
+ Assert.Equal(1, idTokenCallCount);
+ Assert.Equal(1, mcpTokenCallCount);
+ }
+
+ [Fact]
+ public async Task CrossApplicationAccessProvider_InvalidateCache_ForcesRefresh()
+ {
+ var idTokenCallCount = 0;
+ _mockHandler.Handler = request =>
+ {
+ var url = request.RequestUri!.ToString();
+ if (url.Contains(".well-known"))
+ {
+ return JsonResponse(HttpStatusCode.OK, new JsonObject
+ {
+ ["authorization_endpoint"] = "https://auth.example.com/authorize",
+ ["token_endpoint"] = "https://auth.example.com/token",
+ });
+ }
+
+ if (url.Contains("idp.example.com"))
+ {
+ return JsonResponse(HttpStatusCode.OK, new JsonObject
+ {
+ ["access_token"] = "mock-jag",
+ ["issued_token_type"] = "urn:ietf:params:oauth:token-type:id-jag",
+ ["token_type"] = "N_A",
+ });
+ }
+
+ return JsonResponse(HttpStatusCode.OK, new JsonObject
+ {
+ ["access_token"] = $"token-{idTokenCallCount}",
+ ["token_type"] = "Bearer",
+ ["expires_in"] = 3600,
+ });
+ };
+
+ var provider = new CrossApplicationAccessProvider(
+ new CrossApplicationAccessProviderOptions
+ {
+ ClientId = "client-id",
+ IdpTokenEndpoint = "https://idp.example.com/token",
+ IdpClientId = "idp-client-id",
+ IdTokenCallback = (_, _) =>
+ {
+ idTokenCallCount++;
+ return Task.FromResult("mock-id-token");
+ },
+ },
+ _httpClient);
+
+ var ct = TestContext.Current.CancellationToken;
+
+ await provider.GetAccessTokenAsync(
+ new Uri("https://resource.example.com"),
+ new Uri("https://auth.example.com"), ct);
+
+ provider.InvalidateCache();
+
+ await provider.GetAccessTokenAsync(
+ new Uri("https://resource.example.com"),
+ new Uri("https://auth.example.com"), ct);
+
+ Assert.Equal(2, idTokenCallCount);
+ }
+
+ [Fact]
+ public async Task CrossApplicationAccessProvider_IdTokenCallbackReturnsEmpty_ThrowsException()
+ {
+ _mockHandler.Handler = request =>
+ {
+ var url = request.RequestUri!.ToString();
+ if (url.Contains(".well-known"))
+ {
+ return JsonResponse(HttpStatusCode.OK, new JsonObject
+ {
+ ["authorization_endpoint"] = "https://auth.example.com/authorize",
+ ["token_endpoint"] = "https://auth.example.com/token",
+ });
+ }
+
+ return new HttpResponseMessage(HttpStatusCode.NotFound);
+ };
+
+ var provider = new CrossApplicationAccessProvider(
+ new CrossApplicationAccessProviderOptions
+ {
+ ClientId = "client-id",
+ IdpTokenEndpoint = "https://idp.example.com/token",
+ IdpClientId = "idp-client-id",
+ IdTokenCallback = (_, _) => Task.FromResult(string.Empty),
+ },
+ _httpClient);
+
+ await Assert.ThrowsAsync(
+ () => provider.GetAccessTokenAsync(
+ new Uri("https://resource.example.com"),
+ new Uri("https://auth.example.com"),
+ TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public void CrossApplicationAccessProvider_NullOptions_ThrowsArgumentNullException()
+ {
+ Assert.Throws(() => new CrossApplicationAccessProvider(null!, _httpClient));
+ }
+
+ [Fact]
+ public void CrossApplicationAccessProvider_NullHttpClient_ThrowsArgumentNullException()
+ {
+ Assert.Throws(() => new CrossApplicationAccessProvider(
+ new CrossApplicationAccessProviderOptions
+ {
+ ClientId = "client-id",
+ IdpTokenEndpoint = "https://idp.example.com/token",
+ IdpClientId = "idp-client-id",
+ IdTokenCallback = (_, _) => Task.FromResult("token"),
+ },
+ null!));
+ }
+
+ [Fact]
+ public void CrossApplicationAccessProvider_MissingClientId_ThrowsArgumentException()
+ {
+ Assert.Throws(() => new CrossApplicationAccessProvider(
+ new CrossApplicationAccessProviderOptions
+ {
+ ClientId = "",
+ IdpTokenEndpoint = "https://idp.example.com/token",
+ IdpClientId = "idp-client-id",
+ IdTokenCallback = (_, _) => Task.FromResult("test"),
+ },
+ _httpClient));
+ }
+
+ [Fact]
+ public void CrossApplicationAccessProvider_MissingIdTokenCallback_ThrowsArgumentNullException()
+ {
+ Assert.Throws(() => new CrossApplicationAccessProvider(
+ new CrossApplicationAccessProviderOptions
+ {
+ ClientId = "client-id",
+ IdpTokenEndpoint = "https://idp.example.com/token",
+ IdpClientId = "idp-client-id",
+ IdTokenCallback = null!,
+ },
+ _httpClient));
+ }
+
+ [Fact]
+ public void CrossApplicationAccessProvider_MissingIdpConfig_ThrowsArgumentException()
+ {
+ Assert.Throws(() => new CrossApplicationAccessProvider(
+ new CrossApplicationAccessProviderOptions
+ {
+ ClientId = "client-id",
+ IdpClientId = "idp-client-id",
+ // Neither IdpUrl nor IdpTokenEndpoint provided
+ IdTokenCallback = (_, _) => Task.FromResult("test"),
+ },
+ _httpClient));
+ }
+
+ #endregion
+
+ #region CrossApplicationAccessException Tests
+
+ [Fact]
+ public void CrossApplicationAccessException_WithErrorCodeAndDescription_FormatsMessage()
+ {
+ var ex = new CrossApplicationAccessException("Base message", "invalid_grant", "Token expired");
+
+ Assert.Contains("Base message", ex.Message);
+ Assert.Contains("invalid_grant", ex.Message);
+ Assert.Contains("Token expired", ex.Message);
+ Assert.Equal("invalid_grant", ex.ErrorCode);
+ Assert.Equal("Token expired", ex.ErrorDescription);
+ }
+
+ [Fact]
+ public void CrossApplicationAccessException_WithErrorUri_StoresIt()
+ {
+ var ex = new CrossApplicationAccessException("msg", "error", "desc", "https://docs.example.com/error");
+
+ Assert.Equal("https://docs.example.com/error", ex.ErrorUri);
+ }
+
+ [Fact]
+ public void CrossApplicationAccessException_WithoutErrorDetails_PlainMessage()
+ {
+ var ex = new CrossApplicationAccessException("Simple error");
+
+ Assert.Equal("Simple error", ex.Message);
+ Assert.Null(ex.ErrorCode);
+ Assert.Null(ex.ErrorDescription);
+ Assert.Null(ex.ErrorUri);
+ }
+
+ #endregion
+
+ #region Helpers
+
+ private static HttpResponseMessage JsonResponse(HttpStatusCode statusCode, JsonObject payload)
+ {
+ return new HttpResponseMessage(statusCode)
+ {
+ Content = new StringContent(payload.ToJsonString(), System.Text.Encoding.UTF8, "application/json")
+ };
+ }
+
+ private sealed class MockHttpMessageHandler : HttpMessageHandler
+ {
+ public Func? Handler { get; set; }
+ public Func>? AsyncHandler { get; set; }
+
+ protected override async Task SendAsync(
+ HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ if (AsyncHandler is not null)
+ {
+ return await AsyncHandler(request);
+ }
+
+ if (Handler is not null)
+ {
+ return Handler(request);
+ }
+
+ return new HttpResponseMessage(HttpStatusCode.InternalServerError)
+ {
+ Content = new StringContent("No mock response configured")
+ };
+ }
+ }
+
+ #endregion
+}