diff --git a/README.md b/README.md
index 7f5a9e14e..e28c8d91e 100644
--- a/README.md
+++ b/README.md
@@ -31,6 +31,38 @@ For more information about MCP:
- [Protocol Specification](https://modelcontextprotocol.io/specification/)
- [GitHub Organization](https://github.com/modelcontextprotocol)
+## Enterprise Auth / Enterprise Managed Authorization
+
+The SDK provides Cross-Application Access support for the [Identity Assertion Authorization Grant flow](https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/enterprise-managed-authorization.mdx),
+enabling enterprise SSO scenarios where users authenticate once via their enterprise Identity Provider and
+access MCP servers without per-server authorization prompts.
+
+The flow consists of two token operations:
+1. **RFC 8693 Token Exchange** at the IdP: ID Token → JWT Authorization Grant (JAG)
+2. **RFC 7523 JWT Bearer Grant** at the MCP Server: JAG → Access Token
+
+### Using CrossApplicationAccessProvider
+
+```csharp
+using ModelContextProtocol.Authentication;
+
+var provider = new CrossApplicationAccessProvider(new CrossApplicationAccessProviderOptions
+{
+ ClientId = "mcp-client-id",
+ IdpTokenEndpoint = "https://company.okta.com/oauth2/token",
+ IdpClientId = "idp-client-id",
+ IdTokenCallback = async (context, ct) =>
+ {
+ // Return the OIDC ID token from your SSO session
+ return myIdToken;
+ }
+});
+
+var tokens = await provider.GetAccessTokenAsync(
+ resourceUrl: new Uri("https://mcp-server.example.com"),
+ authorizationServerUrl: new Uri("https://auth.mcp-server.example.com"));
+```
+
## License
This project is licensed under the [Apache License 2.0](LICENSE).
diff --git a/src/ModelContextProtocol.Core/Authentication/CrossApplicationAccess.cs b/src/ModelContextProtocol.Core/Authentication/CrossApplicationAccess.cs
new file mode 100644
index 000000000..f0ee76d8d
--- /dev/null
+++ b/src/ModelContextProtocol.Core/Authentication/CrossApplicationAccess.cs
@@ -0,0 +1,362 @@
+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,
+ CancellationToken cancellationToken = default)
+ {
+ Throw.IfNull(options);
+ Throw.IfNullOrEmpty(options.TokenEndpoint, "TokenEndpoint is required.");
+ Throw.IfNullOrEmpty(options.Audience, "Audience is required.");
+ Throw.IfNullOrEmpty(options.Resource, "Resource is required.");
+ Throw.IfNullOrEmpty(options.IdToken, "IdToken is required.");
+ Throw.IfNullOrEmpty(options.ClientId, "ClientId is required.");
+
+ var httpClient = options.HttpClient ?? new HttpClient();
+
+ 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)
+ {
+ throw new CrossApplicationAccessException($"Failed to parse token exchange response: {responseBody}");
+ }
+
+ 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.OrdinalIgnoreCase))
+ {
+ throw new CrossApplicationAccessException(
+ $"Token exchange response token_type must be '{TokenTypeNotApplicable}' per RFC 8693 §2.2.1, got '{response.TokenType}'.");
+ }
+
+ return response.AccessToken;
+ }
+
+ ///
+ /// Discovers the IDP's token endpoint via OAuth/OIDC metadata, then requests a JWT Authorization Grant.
+ /// Convenience wrapper over .
+ ///
+ public static async Task DiscoverAndRequestJwtAuthorizationGrantAsync(
+ DiscoverAndRequestJwtAuthGrantOptions options,
+ CancellationToken cancellationToken = default)
+ {
+ Throw.IfNull(options);
+
+ var tokenEndpoint = options.IdpTokenEndpoint;
+
+ if (string.IsNullOrEmpty(tokenEndpoint))
+ {
+ Throw.IfNullOrEmpty(options.IdpUrl, "Either IdpUrl or IdpTokenEndpoint is required.");
+
+ var httpClient = options.HttpClient ?? new HttpClient();
+ var idpMetadata = await DiscoverAuthServerMetadataAsync(
+ new Uri(options.IdpUrl!), httpClient, cancellationToken).ConfigureAwait(false);
+
+ tokenEndpoint = idpMetadata.TokenEndpoint?.ToString()
+ ?? throw new CrossApplicationAccessException($"IDP metadata discovery for {options.IdpUrl} did not return a token_endpoint.");
+ }
+
+ return await RequestJwtAuthorizationGrantAsync(new RequestJwtAuthGrantOptions
+ {
+ TokenEndpoint = tokenEndpoint!,
+ Audience = options.Audience,
+ Resource = options.Resource,
+ IdToken = options.IdToken,
+ ClientId = options.ClientId,
+ ClientSecret = options.ClientSecret,
+ Scope = options.Scope,
+ HttpClient = options.HttpClient,
+ }, cancellationToken).ConfigureAwait(false);
+ }
+
+ #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,
+ CancellationToken cancellationToken = default)
+ {
+ Throw.IfNull(options);
+ Throw.IfNullOrEmpty(options.TokenEndpoint, "TokenEndpoint is required.");
+ Throw.IfNullOrEmpty(options.Assertion, "Assertion (JAG) is required.");
+ Throw.IfNullOrEmpty(options.ClientId, "ClientId is required.");
+
+ var httpClient = options.HttpClient ?? new HttpClient();
+
+ 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)
+ {
+ throw new CrossApplicationAccessException($"Failed to parse JWT bearer grant response: {responseBody}");
+ }
+
+ 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
+
+ #region Helpers
+
+ private static class Throw
+ {
+ public static void IfNull(T value, [System.Runtime.CompilerServices.CallerArgumentExpression(nameof(value))] string? name = null) where T : class
+ {
+ if (value is null)
+ {
+ throw new ArgumentNullException(name);
+ }
+ }
+
+ public static void IfNullOrEmpty(string? value, string message)
+ {
+ if (string.IsNullOrEmpty(value))
+ {
+ throw new ArgumentException(message);
+ }
+ }
+ }
+
+ #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..05a1a6a80
--- /dev/null
+++ b/src/ModelContextProtocol.Core/Authentication/CrossApplicationAccessProvider.cs
@@ -0,0 +1,212 @@
+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 = async (context, ct) =>
+/// {
+/// // Return the ID token from your SSO session
+/// return myIdToken;
+/// }
+/// });
+///
+/// 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.
+ /// Optional HTTP client. A default will be created if not provided.
+ /// Optional logger factory.
+ /// is null.
+ /// Required option values are missing.
+ public CrossApplicationAccessProvider(
+ CrossApplicationAccessProviderOptions options,
+ HttpClient? httpClient = null,
+ ILoggerFactory? loggerFactory = null)
+ {
+ _options = options ?? throw new ArgumentNullException(nameof(options));
+
+ if (string.IsNullOrEmpty(options.ClientId))
+ {
+ throw new ArgumentException("ClientId is required.", nameof(options));
+ }
+
+ if (string.IsNullOrEmpty(options.IdpClientId))
+ {
+ throw new ArgumentException("IdpClientId is required.", nameof(options));
+ }
+
+ if (string.IsNullOrEmpty(options.IdpUrl) && string.IsNullOrEmpty(options.IdpTokenEndpoint))
+ {
+ throw new ArgumentException("Either IdpUrl or IdpTokenEndpoint is required.", nameof(options));
+ }
+
+ if (options.IdTokenCallback is null)
+ {
+ throw new ArgumentException("IdTokenCallback is required.", nameof(options));
+ }
+
+ _httpClient = httpClient ?? new 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 = _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 = _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/DiscoverAndRequestJwtAuthGrantOptions.cs b/src/ModelContextProtocol.Core/Authentication/DiscoverAndRequestJwtAuthGrantOptions.cs
new file mode 100644
index 000000000..52835f693
--- /dev/null
+++ b/src/ModelContextProtocol.Core/Authentication/DiscoverAndRequestJwtAuthGrantOptions.cs
@@ -0,0 +1,55 @@
+namespace ModelContextProtocol.Authentication;
+
+///
+/// Options for discovering an IDP's token endpoint and requesting a JWT Authorization Grant.
+/// Extends semantics but replaces TokenEndpoint
+/// with IdpUrl/IdpTokenEndpoint for automatic discovery.
+///
+internal sealed class DiscoverAndRequestJwtAuthGrantOptions
+{
+ ///
+ /// Gets or sets the Identity Provider's base URL for OAuth/OIDC discovery.
+ /// Used when is not specified.
+ ///
+ public string? IdpUrl { get; set; }
+
+ ///
+ /// Gets or sets the IDP token endpoint URL. When provided, skips IDP metadata discovery.
+ ///
+ public string? IdpTokenEndpoint { 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; }
+
+ ///
+ /// Gets or sets the HTTP client for making requests.
+ ///
+ public HttpClient? HttpClient { get; set; }
+}
diff --git a/src/ModelContextProtocol.Core/Authentication/ExchangeJwtBearerGrantOptions.cs b/src/ModelContextProtocol.Core/Authentication/ExchangeJwtBearerGrantOptions.cs
new file mode 100644
index 000000000..2cce4f56c
--- /dev/null
+++ b/src/ModelContextProtocol.Core/Authentication/ExchangeJwtBearerGrantOptions.cs
@@ -0,0 +1,37 @@
+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; }
+
+ ///
+ /// Gets or sets the HTTP client for making requests.
+ ///
+ public HttpClient? HttpClient { 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..073783ac3
--- /dev/null
+++ b/src/ModelContextProtocol.Core/Authentication/RequestJwtAuthGrantOptions.cs
@@ -0,0 +1,47 @@
+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; }
+
+ ///
+ /// Gets or sets the HTTP client for making requests. If not provided, a default HttpClient will be used.
+ ///
+ public HttpClient? HttpClient { 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/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj
index b6423b0c8..3939345f5 100644
--- a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj
+++ b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj
@@ -56,6 +56,15 @@
+
+
+ <_Parameter1>ModelContextProtocol.Tests
+
+
+ <_Parameter1>ModelContextProtocol.AspNetCore.Tests
+
+
+
+/// 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..df0572f58
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/CrossApplicationAccessTests.cs
@@ -0,0 +1,949 @@
+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 RequestJwtAuthorizationGrantAsync Tests
+
+ [Fact]
+ public async Task RequestJwtAuthorizationGrantAsync_SuccessfulExchange_ReturnsJag()
+ {
+ var expectedJag = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.test-jag-payload.signature";
+ _mockHandler.Handler = _ => JsonResponse(HttpStatusCode.OK, new JsonObject
+ {
+ ["access_token"] = expectedJag,
+ ["issued_token_type"] = CrossApplicationAccess.TokenTypeIdJag,
+ ["token_type"] = "N_A",
+ ["expires_in"] = 300,
+ });
+
+ var options = new RequestJwtAuthGrantOptions
+ {
+ TokenEndpoint = "https://idp.example.com/oauth2/token",
+ Audience = "https://auth.mcp-server.example.com",
+ Resource = "https://mcp-server.example.com",
+ IdToken = "test-id-token",
+ ClientId = "test-client-id",
+ HttpClient = _httpClient,
+ };
+
+ var jag = await CrossApplicationAccess.RequestJwtAuthorizationGrantAsync(options, TestContext.Current.CancellationToken);
+
+ Assert.Equal(expectedJag, jag);
+ }
+
+ [Fact]
+ public async Task RequestJwtAuthorizationGrantAsync_SendsCorrectFormData()
+ {
+ string? capturedBody = null;
+ _mockHandler.AsyncHandler = async request =>
+ {
+ capturedBody = await request.Content!.ReadAsStringAsync();
+ return JsonResponse(HttpStatusCode.OK, new JsonObject
+ {
+ ["access_token"] = "test-jag",
+ ["issued_token_type"] = CrossApplicationAccess.TokenTypeIdJag,
+ ["token_type"] = "N_A",
+ });
+ };
+
+ var options = new RequestJwtAuthGrantOptions
+ {
+ TokenEndpoint = "https://idp.example.com/oauth2/token",
+ Audience = "https://auth.mcp-server.example.com",
+ Resource = "https://mcp-server.example.com",
+ IdToken = "my-id-token",
+ ClientId = "my-client-id",
+ ClientSecret = "my-secret",
+ Scope = "openid email",
+ HttpClient = _httpClient,
+ };
+
+ await CrossApplicationAccess.RequestJwtAuthorizationGrantAsync(options, TestContext.Current.CancellationToken);
+
+ Assert.NotNull(capturedBody);
+ var formParams = ParseFormData(capturedBody!);
+ Assert.Equal(CrossApplicationAccess.GrantTypeTokenExchange, formParams["grant_type"]);
+ Assert.Equal(CrossApplicationAccess.TokenTypeIdJag, formParams["requested_token_type"]);
+ Assert.Equal("my-id-token", formParams["subject_token"]);
+ Assert.Equal(CrossApplicationAccess.TokenTypeIdToken, formParams["subject_token_type"]);
+ Assert.Equal("https://auth.mcp-server.example.com", formParams["audience"]);
+ Assert.Equal("https://mcp-server.example.com", formParams["resource"]);
+ Assert.Equal("my-client-id", formParams["client_id"]);
+ Assert.Equal("my-secret", formParams["client_secret"]);
+ Assert.Equal("openid email", formParams["scope"]);
+ }
+
+ [Fact]
+ public async Task RequestJwtAuthorizationGrantAsync_WithoutOptionalParams_OmitsThem()
+ {
+ string? capturedBody = null;
+ _mockHandler.AsyncHandler = async request =>
+ {
+ capturedBody = await request.Content!.ReadAsStringAsync();
+ return JsonResponse(HttpStatusCode.OK, new JsonObject
+ {
+ ["access_token"] = "test-jag",
+ ["issued_token_type"] = CrossApplicationAccess.TokenTypeIdJag,
+ ["token_type"] = "N_A",
+ });
+ };
+
+ var options = new RequestJwtAuthGrantOptions
+ {
+ TokenEndpoint = "https://idp.example.com/token",
+ Audience = "https://auth.example.com",
+ Resource = "https://resource.example.com",
+ IdToken = "test-id-token",
+ ClientId = "test-client-id",
+ HttpClient = _httpClient,
+ };
+
+ await CrossApplicationAccess.RequestJwtAuthorizationGrantAsync(options, TestContext.Current.CancellationToken);
+
+ var formParams = ParseFormData(capturedBody!);
+ Assert.False(formParams.ContainsKey("client_secret"));
+ Assert.False(formParams.ContainsKey("scope"));
+ }
+
+ [Fact]
+ public async Task RequestJwtAuthorizationGrantAsync_ServerError_ThrowsCrossApplicationAccessException()
+ {
+ _mockHandler.Handler = _ => JsonResponse(HttpStatusCode.BadRequest, new JsonObject
+ {
+ ["error"] = "invalid_request",
+ ["error_description"] = "Missing required parameter: subject_token",
+ });
+
+ var options = new RequestJwtAuthGrantOptions
+ {
+ TokenEndpoint = "https://idp.example.com/token",
+ Audience = "https://auth.example.com",
+ Resource = "https://resource.example.com",
+ IdToken = "test-id-token",
+ ClientId = "test-client-id",
+ HttpClient = _httpClient,
+ };
+
+ var ex = await Assert.ThrowsAsync(
+ () => CrossApplicationAccess.RequestJwtAuthorizationGrantAsync(options, TestContext.Current.CancellationToken));
+ Assert.Equal("invalid_request", ex.ErrorCode);
+ Assert.Equal("Missing required parameter: subject_token", ex.ErrorDescription);
+ Assert.Contains("400", ex.Message);
+ }
+
+ [Fact]
+ public async Task RequestJwtAuthorizationGrantAsync_WrongIssuedTokenType_ThrowsException()
+ {
+ _mockHandler.Handler = _ => JsonResponse(HttpStatusCode.OK, new JsonObject
+ {
+ ["access_token"] = "test-jag",
+ ["issued_token_type"] = "urn:ietf:params:oauth:token-type:access_token",
+ ["token_type"] = "N_A",
+ });
+
+ var options = new RequestJwtAuthGrantOptions
+ {
+ TokenEndpoint = "https://idp.example.com/token",
+ Audience = "https://auth.example.com",
+ Resource = "https://resource.example.com",
+ IdToken = "test-id-token",
+ ClientId = "test-client-id",
+ HttpClient = _httpClient,
+ };
+
+ var ex = await Assert.ThrowsAsync(
+ () => CrossApplicationAccess.RequestJwtAuthorizationGrantAsync(options, TestContext.Current.CancellationToken));
+ Assert.Contains("issued_token_type", ex.Message);
+ Assert.Contains(CrossApplicationAccess.TokenTypeIdJag, ex.Message);
+ }
+
+ [Fact]
+ public async Task RequestJwtAuthorizationGrantAsync_WrongTokenType_ThrowsException()
+ {
+ _mockHandler.Handler = _ => JsonResponse(HttpStatusCode.OK, new JsonObject
+ {
+ ["access_token"] = "test-jag",
+ ["issued_token_type"] = CrossApplicationAccess.TokenTypeIdJag,
+ ["token_type"] = "Bearer",
+ });
+
+ var options = new RequestJwtAuthGrantOptions
+ {
+ TokenEndpoint = "https://idp.example.com/token",
+ Audience = "https://auth.example.com",
+ Resource = "https://resource.example.com",
+ IdToken = "test-id-token",
+ ClientId = "test-client-id",
+ HttpClient = _httpClient,
+ };
+
+ var ex = await Assert.ThrowsAsync(
+ () => CrossApplicationAccess.RequestJwtAuthorizationGrantAsync(options, TestContext.Current.CancellationToken));
+ Assert.Contains("token_type", ex.Message);
+ Assert.Contains("N_A", ex.Message);
+ }
+
+ [Fact]
+ public async Task RequestJwtAuthorizationGrantAsync_TokenTypeNa_CaseInsensitive()
+ {
+ _mockHandler.Handler = _ => JsonResponse(HttpStatusCode.OK, new JsonObject
+ {
+ ["access_token"] = "test-jag",
+ ["issued_token_type"] = CrossApplicationAccess.TokenTypeIdJag,
+ ["token_type"] = "n_a",
+ });
+
+ var options = new RequestJwtAuthGrantOptions
+ {
+ TokenEndpoint = "https://idp.example.com/token",
+ Audience = "https://auth.example.com",
+ Resource = "https://resource.example.com",
+ IdToken = "test-id-token",
+ ClientId = "test-client-id",
+ HttpClient = _httpClient,
+ };
+
+ var jag = await CrossApplicationAccess.RequestJwtAuthorizationGrantAsync(options, TestContext.Current.CancellationToken);
+ Assert.Equal("test-jag", jag);
+ }
+
+ [Fact]
+ public async Task RequestJwtAuthorizationGrantAsync_MissingAccessToken_ThrowsException()
+ {
+ _mockHandler.Handler = _ => JsonResponse(HttpStatusCode.OK, new JsonObject
+ {
+ ["issued_token_type"] = CrossApplicationAccess.TokenTypeIdJag,
+ ["token_type"] = "N_A",
+ });
+
+ var options = new RequestJwtAuthGrantOptions
+ {
+ TokenEndpoint = "https://idp.example.com/token",
+ Audience = "https://auth.example.com",
+ Resource = "https://resource.example.com",
+ IdToken = "test-id-token",
+ ClientId = "test-client-id",
+ HttpClient = _httpClient,
+ };
+
+ var ex = await Assert.ThrowsAsync(
+ () => CrossApplicationAccess.RequestJwtAuthorizationGrantAsync(options, TestContext.Current.CancellationToken));
+ Assert.Contains("access_token", ex.Message);
+ }
+
+ [Fact]
+ public async Task RequestJwtAuthorizationGrantAsync_NullOptions_ThrowsArgumentNullException()
+ {
+ await Assert.ThrowsAsync(
+ () => CrossApplicationAccess.RequestJwtAuthorizationGrantAsync(null!, TestContext.Current.CancellationToken));
+ }
+
+ [Theory]
+ [InlineData("", "https://a.com", "https://r.com", "token", "client")]
+ [InlineData("https://t.com", "", "https://r.com", "token", "client")]
+ [InlineData("https://t.com", "https://a.com", "", "token", "client")]
+ [InlineData("https://t.com", "https://a.com", "https://r.com", "", "client")]
+ [InlineData("https://t.com", "https://a.com", "https://r.com", "token", "")]
+ public async Task RequestJwtAuthorizationGrantAsync_MissingRequiredField_ThrowsArgumentException(
+ string tokenEndpoint, string audience, string resource, string idToken, string clientId)
+ {
+ var options = new RequestJwtAuthGrantOptions
+ {
+ TokenEndpoint = tokenEndpoint,
+ Audience = audience,
+ Resource = resource,
+ IdToken = idToken,
+ ClientId = clientId,
+ HttpClient = _httpClient,
+ };
+
+ await Assert.ThrowsAsync(
+ () => CrossApplicationAccess.RequestJwtAuthorizationGrantAsync(options, TestContext.Current.CancellationToken));
+ }
+
+ #endregion
+
+ #region ExchangeJwtBearerGrantAsync Tests
+
+ [Fact]
+ public async Task ExchangeJwtBearerGrantAsync_SuccessfulExchange_ReturnsTokenContainer()
+ {
+ _mockHandler.Handler = _ => JsonResponse(HttpStatusCode.OK, new JsonObject
+ {
+ ["access_token"] = "mcp-access-token",
+ ["token_type"] = "Bearer",
+ ["expires_in"] = 3600,
+ ["refresh_token"] = "mcp-refresh-token",
+ ["scope"] = "mcp:read mcp:write",
+ });
+
+ var options = new ExchangeJwtBearerGrantOptions
+ {
+ TokenEndpoint = "https://auth.mcp-server.example.com/token",
+ Assertion = "test-jag-assertion",
+ ClientId = "mcp-client-id",
+ HttpClient = _httpClient,
+ };
+
+ var tokens = await CrossApplicationAccess.ExchangeJwtBearerGrantAsync(options, TestContext.Current.CancellationToken);
+
+ Assert.Equal("mcp-access-token", tokens.AccessToken);
+ Assert.Equal("Bearer", tokens.TokenType);
+ Assert.Equal(3600, tokens.ExpiresIn);
+ Assert.Equal("mcp-refresh-token", tokens.RefreshToken);
+ Assert.Equal("mcp:read mcp:write", tokens.Scope);
+ }
+
+ [Fact]
+ public async Task ExchangeJwtBearerGrantAsync_SendsCorrectFormData()
+ {
+ string? capturedBody = null;
+ _mockHandler.AsyncHandler = async request =>
+ {
+ capturedBody = await request.Content!.ReadAsStringAsync();
+ return JsonResponse(HttpStatusCode.OK, new JsonObject
+ {
+ ["access_token"] = "token",
+ ["token_type"] = "Bearer",
+ });
+ };
+
+ var options = new ExchangeJwtBearerGrantOptions
+ {
+ TokenEndpoint = "https://auth.example.com/token",
+ Assertion = "my-jag-assertion",
+ ClientId = "my-client-id",
+ ClientSecret = "my-client-secret",
+ Scope = "read write",
+ HttpClient = _httpClient,
+ };
+
+ await CrossApplicationAccess.ExchangeJwtBearerGrantAsync(options, TestContext.Current.CancellationToken);
+
+ Assert.NotNull(capturedBody);
+ var formParams = ParseFormData(capturedBody!);
+ Assert.Equal(CrossApplicationAccess.GrantTypeJwtBearer, formParams["grant_type"]);
+ Assert.Equal("my-jag-assertion", formParams["assertion"]);
+ Assert.Equal("my-client-id", formParams["client_id"]);
+ Assert.Equal("my-client-secret", formParams["client_secret"]);
+ Assert.Equal("read write", formParams["scope"]);
+ }
+
+ [Fact]
+ public async Task ExchangeJwtBearerGrantAsync_ServerError_ThrowsCrossApplicationAccessException()
+ {
+ _mockHandler.Handler = _ => JsonResponse(HttpStatusCode.Unauthorized, new JsonObject
+ {
+ ["error"] = "invalid_grant",
+ ["error_description"] = "The JAG assertion is expired",
+ });
+
+ var options = new ExchangeJwtBearerGrantOptions
+ {
+ TokenEndpoint = "https://auth.example.com/token",
+ Assertion = "expired-jag",
+ ClientId = "client-id",
+ HttpClient = _httpClient,
+ };
+
+ var ex = await Assert.ThrowsAsync(
+ () => CrossApplicationAccess.ExchangeJwtBearerGrantAsync(options, TestContext.Current.CancellationToken));
+ Assert.Equal("invalid_grant", ex.ErrorCode);
+ Assert.Equal("The JAG assertion is expired", ex.ErrorDescription);
+ }
+
+ [Fact]
+ public async Task ExchangeJwtBearerGrantAsync_NonBearerTokenType_ThrowsException()
+ {
+ _mockHandler.Handler = _ => JsonResponse(HttpStatusCode.OK, new JsonObject
+ {
+ ["access_token"] = "token",
+ ["token_type"] = "mac",
+ });
+
+ var options = new ExchangeJwtBearerGrantOptions
+ {
+ TokenEndpoint = "https://auth.example.com/token",
+ Assertion = "test-jag",
+ ClientId = "client-id",
+ HttpClient = _httpClient,
+ };
+
+ var ex = await Assert.ThrowsAsync(
+ () => CrossApplicationAccess.ExchangeJwtBearerGrantAsync(options, TestContext.Current.CancellationToken));
+ Assert.Contains("token_type", ex.Message);
+ Assert.Contains("bearer", ex.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task ExchangeJwtBearerGrantAsync_BearerCaseInsensitive()
+ {
+ _mockHandler.Handler = _ => JsonResponse(HttpStatusCode.OK, new JsonObject
+ {
+ ["access_token"] = "token",
+ ["token_type"] = "BEARER",
+ });
+
+ var options = new ExchangeJwtBearerGrantOptions
+ {
+ TokenEndpoint = "https://auth.example.com/token",
+ Assertion = "test-jag",
+ ClientId = "client-id",
+ HttpClient = _httpClient,
+ };
+
+ var tokens = await CrossApplicationAccess.ExchangeJwtBearerGrantAsync(options, TestContext.Current.CancellationToken);
+ Assert.Equal("token", tokens.AccessToken);
+ }
+
+ [Fact]
+ public async Task ExchangeJwtBearerGrantAsync_MissingAccessToken_ThrowsException()
+ {
+ _mockHandler.Handler = _ => JsonResponse(HttpStatusCode.OK, new JsonObject
+ {
+ ["token_type"] = "Bearer",
+ });
+
+ var options = new ExchangeJwtBearerGrantOptions
+ {
+ TokenEndpoint = "https://auth.example.com/token",
+ Assertion = "test-jag",
+ ClientId = "client-id",
+ HttpClient = _httpClient,
+ };
+
+ var ex = await Assert.ThrowsAsync(
+ () => CrossApplicationAccess.ExchangeJwtBearerGrantAsync(options, TestContext.Current.CancellationToken));
+ Assert.Contains("access_token", ex.Message);
+ }
+
+ [Fact]
+ public async Task ExchangeJwtBearerGrantAsync_NullOptions_ThrowsArgumentNullException()
+ {
+ await Assert.ThrowsAsync(
+ () => CrossApplicationAccess.ExchangeJwtBearerGrantAsync(null!, TestContext.Current.CancellationToken));
+ }
+
+ #endregion
+
+ #region DiscoverAndRequestJwtAuthorizationGrantAsync Tests
+
+ [Fact]
+ public async Task DiscoverAndRequestJwtAuthorizationGrantAsync_WithIdpUrl_DiscoversAndExchanges()
+ {
+ var expectedJag = "discovered-jag-token";
+ var requestCount = 0;
+ _mockHandler.Handler = request =>
+ {
+ requestCount++;
+ var url = request.RequestUri!.ToString();
+
+ if (url.Contains(".well-known/openid-configuration"))
+ {
+ return JsonResponse(HttpStatusCode.OK, new JsonObject
+ {
+ ["issuer"] = "https://idp.example.com",
+ ["authorization_endpoint"] = "https://idp.example.com/authorize",
+ ["token_endpoint"] = "https://idp.example.com/oauth2/token",
+ });
+ }
+
+ if (url.Contains("/oauth2/token"))
+ {
+ return JsonResponse(HttpStatusCode.OK, new JsonObject
+ {
+ ["access_token"] = expectedJag,
+ ["issued_token_type"] = CrossApplicationAccess.TokenTypeIdJag,
+ ["token_type"] = "N_A",
+ });
+ }
+
+ return new HttpResponseMessage(HttpStatusCode.NotFound);
+ };
+
+ var options = new DiscoverAndRequestJwtAuthGrantOptions
+ {
+ IdpUrl = "https://idp.example.com",
+ Audience = "https://auth.mcp-server.example.com",
+ Resource = "https://mcp-server.example.com",
+ IdToken = "test-id-token",
+ ClientId = "test-client-id",
+ HttpClient = _httpClient,
+ };
+
+ var jag = await CrossApplicationAccess.DiscoverAndRequestJwtAuthorizationGrantAsync(options, TestContext.Current.CancellationToken);
+
+ Assert.Equal(expectedJag, jag);
+ Assert.True(requestCount >= 2, "Should make at least 2 requests (discovery + exchange)");
+ }
+
+ [Fact]
+ public async Task DiscoverAndRequestJwtAuthorizationGrantAsync_WithDirectTokenEndpoint_SkipsDiscovery()
+ {
+ _mockHandler.Handler = request =>
+ {
+ var url = request.RequestUri!.ToString();
+ if (url.Contains(".well-known"))
+ {
+ throw new InvalidOperationException("Should not attempt discovery when IdpTokenEndpoint is provided");
+ }
+
+ return JsonResponse(HttpStatusCode.OK, new JsonObject
+ {
+ ["access_token"] = "direct-jag",
+ ["issued_token_type"] = CrossApplicationAccess.TokenTypeIdJag,
+ ["token_type"] = "N_A",
+ });
+ };
+
+ var options = new DiscoverAndRequestJwtAuthGrantOptions
+ {
+ IdpTokenEndpoint = "https://idp.example.com/oauth2/token",
+ Audience = "https://auth.example.com",
+ Resource = "https://resource.example.com",
+ IdToken = "test-id-token",
+ ClientId = "test-client-id",
+ HttpClient = _httpClient,
+ };
+
+ var jag = await CrossApplicationAccess.DiscoverAndRequestJwtAuthorizationGrantAsync(options, TestContext.Current.CancellationToken);
+
+ Assert.Equal("direct-jag", jag);
+ }
+
+ [Fact]
+ public async Task DiscoverAndRequestJwtAuthorizationGrantAsync_DiscoveryFails_ThrowsException()
+ {
+ _mockHandler.Handler = _ => new HttpResponseMessage(HttpStatusCode.NotFound);
+
+ var options = new DiscoverAndRequestJwtAuthGrantOptions
+ {
+ IdpUrl = "https://idp.example.com",
+ Audience = "https://auth.example.com",
+ Resource = "https://resource.example.com",
+ IdToken = "test-id-token",
+ ClientId = "test-client-id",
+ HttpClient = _httpClient,
+ };
+
+ await Assert.ThrowsAsync(
+ () => CrossApplicationAccess.DiscoverAndRequestJwtAuthorizationGrantAsync(options, TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task DiscoverAndRequestJwtAuthorizationGrantAsync_NoIdpUrlOrTokenEndpoint_ThrowsException()
+ {
+ var options = new DiscoverAndRequestJwtAuthGrantOptions
+ {
+ Audience = "https://auth.example.com",
+ Resource = "https://resource.example.com",
+ IdToken = "test-id-token",
+ ClientId = "test-client-id",
+ HttpClient = _httpClient,
+ };
+
+ await Assert.ThrowsAsync(
+ () => CrossApplicationAccess.DiscoverAndRequestJwtAuthorizationGrantAsync(options, TestContext.Current.CancellationToken));
+ }
+
+ #endregion
+
+ #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"] = CrossApplicationAccess.TokenTypeIdJag,
+ ["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"] = CrossApplicationAccess.TokenTypeIdJag,
+ ["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"] = CrossApplicationAccess.TokenTypeIdJag,
+ ["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!));
+ }
+
+ [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"),
+ }));
+ }
+
+ [Fact]
+ public void CrossApplicationAccessProvider_MissingIdTokenCallback_ThrowsArgumentException()
+ {
+ Assert.Throws(() => new CrossApplicationAccessProvider(
+ new CrossApplicationAccessProviderOptions
+ {
+ ClientId = "client-id",
+ IdpTokenEndpoint = "https://idp.example.com/token",
+ IdpClientId = "idp-client-id",
+ IdTokenCallback = null!,
+ }));
+ }
+
+ [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"),
+ }));
+ }
+
+ #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 Constants Tests
+
+ [Fact]
+ public void Constants_AreCorrectValues()
+ {
+ Assert.Equal("urn:ietf:params:oauth:grant-type:token-exchange", CrossApplicationAccess.GrantTypeTokenExchange);
+ Assert.Equal("urn:ietf:params:oauth:grant-type:jwt-bearer", CrossApplicationAccess.GrantTypeJwtBearer);
+ Assert.Equal("urn:ietf:params:oauth:token-type:id_token", CrossApplicationAccess.TokenTypeIdToken);
+ Assert.Equal("urn:ietf:params:oauth:token-type:saml2", CrossApplicationAccess.TokenTypeSaml2);
+ Assert.Equal("urn:ietf:params:oauth:token-type:id-jag", CrossApplicationAccess.TokenTypeIdJag);
+ Assert.Equal("N_A", CrossApplicationAccess.TokenTypeNotApplicable);
+ }
+
+ #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 static Dictionary ParseFormData(string formData)
+ {
+ var result = new Dictionary();
+ foreach (var pair in formData.Split('&'))
+ {
+ var idx = pair.IndexOf('=');
+ if (idx >= 0)
+ {
+ var key = pair.Substring(0, idx);
+ var value = pair.Substring(idx + 1);
+ result[Uri.UnescapeDataString(key.Replace('+', ' '))] = Uri.UnescapeDataString(value.Replace('+', ' '));
+ }
+ }
+ return result;
+ }
+
+ 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
+}