diff --git a/README.md b/README.md
index d282e9af3..dcc3f8dc1 100644
--- a/README.md
+++ b/README.md
@@ -236,6 +236,67 @@ This attribute may be placed on a method to provide for the tool, prompt, or res
XML comments may also be used; if an `[McpServerTool]`, `[McpServerPrompt]`, or `[McpServerResource]`-attributed method is marked as `partial`,
XML comments placed on the method will be used automatically to generate `[Description]` attributes for the method and its parameters.
+## Enterprise Auth / Enterprise Managed Authorization (SEP-990)
+
+The SDK provides Enterprise Auth utilities for the Identity Assertion Authorization Grant flow (SEP-990),
+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 the Layer 2 utilities directly
+
+```csharp
+using ModelContextProtocol.Authentication;
+
+// Step 1: Exchange ID token for a JAG at the enterprise IdP
+var jag = await EnterpriseAuth.DiscoverAndRequestJwtAuthorizationGrantAsync(
+ new DiscoverAndRequestJwtAuthGrantOptions
+ {
+ IdpUrl = "https://company.okta.com",
+ Audience = "https://auth.mcp-server.example.com",
+ Resource = "https://mcp-server.example.com",
+ IdToken = myIdToken, // obtained via SSO/OIDC login
+ ClientId = "idp-client-id",
+ });
+
+// Step 2: Exchange JAG for an access token at the MCP authorization server
+var tokens = await EnterpriseAuth.ExchangeJwtBearerGrantAsync(
+ new ExchangeJwtBearerGrantOptions
+ {
+ TokenEndpoint = "https://auth.mcp-server.example.com/token",
+ Assertion = jag,
+ ClientId = "mcp-client-id",
+ });
+```
+
+### Using the EnterpriseAuthProvider (Layer 3)
+
+```csharp
+var provider = new EnterpriseAuthProvider(new EnterpriseAuthProviderOptions
+{
+ ClientId = "mcp-client-id",
+ AssertionCallback = async (context, ct) =>
+ {
+ return await EnterpriseAuth.DiscoverAndRequestJwtAuthorizationGrantAsync(
+ new DiscoverAndRequestJwtAuthGrantOptions
+ {
+ IdpUrl = "https://company.okta.com",
+ Audience = context.AuthorizationServerUrl.ToString(),
+ Resource = context.ResourceUrl.ToString(),
+ IdToken = myIdToken,
+ ClientId = "idp-client-id",
+ }, ct);
+ }
+});
+
+var tokens = await provider.GetAccessTokenAsync(
+ resourceUrl: new Uri("https://mcp-server.example.com"),
+ authorizationServerUrl: new Uri("https://auth.mcp-server.example.com"));
+```
+
## Acknowledgements
The starting point for this library was a project called [mcpdotnet](https://github.com/PederHP/mcpdotnet), initiated by [Peder Holdgaard Pedersen](https://github.com/PederHP). We are grateful for the work done by Peder and other contributors to that repository, which created a solid foundation for this library.
diff --git a/src/ModelContextProtocol.Core/Authentication/EnterpriseAuth.cs b/src/ModelContextProtocol.Core/Authentication/EnterpriseAuth.cs
new file mode 100644
index 000000000..6b5c1f886
--- /dev/null
+++ b/src/ModelContextProtocol.Core/Authentication/EnterpriseAuth.cs
@@ -0,0 +1,683 @@
+using System.Net.Http.Headers;
+using System.Text.Json;
+
+namespace ModelContextProtocol.Authentication;
+
+///
+/// Provides Enterprise Managed Authorization utilities for the Identity Assertion Authorization Grant flow (SEP-990).
+///
+///
+///
+/// This class provides standalone functions for:
+///
+///
+/// - RFC 8693 Token Exchange: Exchange an ID token for a JWT Authorization Grant (JAG) at an Identity Provider
+/// - RFC 7523 JWT Bearer Grant: Exchange a JAG for an access token at an MCP Server's authorization server
+///
+///
+/// These utilities can be used directly or through the for full integration
+/// with the MCP client's OAuth infrastructure.
+///
+///
+public static class EnterpriseAuth
+{
+ #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 (SEP-990).
+ ///
+ 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 Layer 2: 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.
+ ///
+ /// Options for the token exchange request.
+ /// The to monitor for cancellation requests.
+ /// The JAG JWT string.
+ /// Thrown when is null.
+ /// Thrown when required option values are missing.
+ /// Thrown when the token exchange request fails.
+ 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 EnterpriseAuthException(
+ $"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 EnterpriseAuthException($"Failed to parse token exchange response: {responseBody}");
+ }
+
+ if (string.IsNullOrEmpty(response.AccessToken))
+ {
+ throw new EnterpriseAuthException("Token exchange response missing required field: access_token");
+ }
+
+ if (!string.Equals(response.IssuedTokenType, TokenTypeIdJag, StringComparison.Ordinal))
+ {
+ throw new EnterpriseAuthException(
+ $"Token exchange response issued_token_type must be '{TokenTypeIdJag}', got '{response.IssuedTokenType}'.");
+ }
+
+ if (!string.Equals(response.TokenType, TokenTypeNotApplicable, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new EnterpriseAuthException(
+ $"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 .
+ ///
+ /// Options for discovery and token exchange. Provides IdpUrl instead of TokenEndpoint.
+ /// The to monitor for cancellation requests.
+ /// The JAG JWT string.
+ /// Thrown when is null.
+ /// Thrown when IDP discovery or token exchange fails.
+ 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 EnterpriseAuthException($"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 Layer 2: 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).
+ ///
+ /// Options for the JWT bearer grant request.
+ /// The to monitor for cancellation requests.
+ /// A containing the access token.
+ /// Thrown when is null.
+ /// Thrown when the JWT bearer grant fails.
+ 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 EnterpriseAuthException(
+ $"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 EnterpriseAuthException($"Failed to parse JWT bearer grant response: {responseBody}");
+ }
+
+ if (string.IsNullOrEmpty(response.AccessToken))
+ {
+ throw new EnterpriseAuthException("JWT bearer grant response missing required field: access_token");
+ }
+
+ if (string.IsNullOrEmpty(response.TokenType))
+ {
+ throw new EnterpriseAuthException("JWT bearer grant response missing required field: token_type");
+ }
+
+ if (!string.Equals(response.TokenType, "bearer", StringComparison.OrdinalIgnoreCase))
+ {
+ throw new EnterpriseAuthException(
+ $"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 EnterpriseAuthException($"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
+}
+
+#region Options Types
+
+///
+/// Options for requesting a JWT Authorization Grant from an Identity Provider via RFC 8693 Token Exchange.
+///
+public 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; }
+}
+
+///
+/// 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.
+///
+public 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; }
+}
+
+///
+/// Options for exchanging a JWT Authorization Grant for an access token via RFC 7523.
+///
+public 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; }
+}
+
+#endregion
+
+#region Response Types
+
+///
+/// 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),
+ /// for SEP-990 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.
+ /// For SEP-990, this MUST be .
+ ///
+ [System.Text.Json.Serialization.JsonPropertyName("issued_token_type")]
+ public string IssuedTokenType { get; set; } = null!;
+
+ ///
+ /// Gets or sets the token type. For SEP-990, 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; }
+}
+
+///
+/// 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; }
+}
+
+///
+/// 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; }
+}
+
+#endregion
+
+#region Exception Type
+
+///
+/// Represents an error that occurred during Enterprise Managed Authorization (SEP-990) operations,
+/// including token exchange (RFC 8693) and JWT bearer grant (RFC 7523) failures.
+///
+public sealed class EnterpriseAuthException : 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 EnterpriseAuthException(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;
+ }
+}
+
+#endregion
diff --git a/src/ModelContextProtocol.Core/Authentication/EnterpriseAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/EnterpriseAuthProvider.cs
new file mode 100644
index 000000000..d097a23d7
--- /dev/null
+++ b/src/ModelContextProtocol.Core/Authentication/EnterpriseAuthProvider.cs
@@ -0,0 +1,232 @@
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace ModelContextProtocol.Authentication;
+
+///
+/// Context provided to the assertion callback for Enterprise Managed Authorization (SEP-990).
+/// Contains the URLs discovered during the OAuth flow that are needed for the token exchange step.
+///
+public sealed class EnterpriseAuthAssertionContext
+{
+ ///
+ /// 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; }
+}
+
+///
+/// Provides Enterprise Managed Authorization (SEP-990) 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:
+///
+///
+/// -
+/// The is called to obtain a JWT Authorization Grant (JAG).
+/// The callback receives a with the discovered
+/// resource URL and authorization server URL. Typically, the callback calls
+/// or
+/// to perform the RFC 8693
+/// Token Exchange at the enterprise IdP.
+///
+/// -
+/// The returned 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 EnterpriseAuthProvider(new EnterpriseAuthProviderOptions
+/// {
+/// ClientId = "my-mcp-client-id",
+/// AssertionCallback = async (context, ct) =>
+/// {
+/// // Use Layer 2 utility to get a JAG from the enterprise IdP
+/// return await EnterpriseAuth.DiscoverAndRequestJwtAuthorizationGrantAsync(
+/// new DiscoverAndRequestJwtAuthGrantOptions
+/// {
+/// IdpUrl = "https://company.okta.com",
+/// Audience = context.AuthorizationServerUrl.ToString(),
+/// Resource = context.ResourceUrl.ToString(),
+/// IdToken = myIdToken, // from SSO login
+/// ClientId = "idp-client-id",
+/// }, ct);
+/// }
+/// });
+///
+/// // Use with MCP client transport
+/// 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 EnterpriseAuthProvider
+{
+ private readonly EnterpriseAuthProviderOptions _options;
+ private readonly HttpClient _httpClient;
+ private readonly ILogger _logger;
+
+ private TokenContainer? _cachedTokens;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Configuration for the Enterprise Auth provider.
+ /// Optional HTTP client. A default will be created if not provided.
+ /// Optional logger factory.
+ /// is null.
+ /// Required option values are missing.
+ public EnterpriseAuthProvider(
+ EnterpriseAuthProviderOptions 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 (options.AssertionCallback is null)
+ {
+ throw new ArgumentException("AssertionCallback is required.", nameof(options));
+ }
+
+ _httpClient = httpClient ?? new HttpClient();
+ _logger = (ILogger?)loggerFactory?.CreateLogger() ?? NullLogger.Instance;
+ }
+
+ ///
+ /// Gets or sets the assertion callback that is invoked to obtain a JWT Authorization Grant (JAG).
+ ///
+ ///
+ /// The callback receives a containing
+ /// the resource and authorization server URLs discovered during the OAuth flow.
+ /// It should return the JAG JWT string obtained via token exchange at the enterprise IdP.
+ ///
+ public Func> AssertionCallback
+ => _options.AssertionCallback!;
+
+ ///
+ /// Performs the full Enterprise Auth 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 the assertion callback or JWT bearer grant fails.
+ public async Task GetAccessTokenAsync(
+ Uri resourceUrl,
+ Uri authorizationServerUrl,
+ CancellationToken cancellationToken = default)
+ {
+ // Check cache first
+ if (_cachedTokens is not null && !_cachedTokens.IsExpired)
+ {
+ return _cachedTokens;
+ }
+
+ _logger.LogDebug("Starting Enterprise Auth flow for resource {ResourceUrl}", resourceUrl);
+
+ // Step 1: Discover MCP auth server metadata to find token endpoint
+ var mcpAuthMetadata = await EnterpriseAuth.DiscoverAuthServerMetadataAsync(
+ authorizationServerUrl, _httpClient, cancellationToken).ConfigureAwait(false);
+
+ var tokenEndpoint = mcpAuthMetadata.TokenEndpoint?.ToString()
+ ?? throw new EnterpriseAuthException(
+ $"MCP authorization server metadata at {authorizationServerUrl} missing token_endpoint.");
+
+ // Step 2: Call the assertion callback to get the JAG
+ var context = new EnterpriseAuthAssertionContext
+ {
+ ResourceUrl = resourceUrl,
+ AuthorizationServerUrl = authorizationServerUrl,
+ };
+
+ _logger.LogDebug("Requesting assertion (JAG) via callback");
+ var jag = await AssertionCallback(context, cancellationToken).ConfigureAwait(false);
+
+ if (string.IsNullOrEmpty(jag))
+ {
+ throw new EnterpriseAuthException("Assertion callback returned a null or empty JAG.");
+ }
+
+ // Step 3: Exchange JAG for access token via JWT Bearer grant (RFC 7523)
+ _logger.LogDebug("Exchanging JAG for access token at {TokenEndpoint}", tokenEndpoint);
+ var tokens = await EnterpriseAuth.ExchangeJwtBearerGrantAsync(
+ new ExchangeJwtBearerGrantOptions
+ {
+ TokenEndpoint = tokenEndpoint,
+ Assertion = jag,
+ ClientId = _options.ClientId!,
+ ClientSecret = _options.ClientSecret,
+ Scope = _options.Scope,
+ HttpClient = _httpClient,
+ }, cancellationToken).ConfigureAwait(false);
+
+ _cachedTokens = tokens;
+ _logger.LogDebug("Enterprise Auth flow completed successfully");
+
+ return tokens;
+ }
+
+ ///
+ /// Clears any cached tokens, forcing a fresh token exchange on the next call.
+ ///
+ public void InvalidateCache()
+ {
+ _cachedTokens = null;
+ }
+}
+
+///
+/// Configuration options for the .
+///
+public sealed class EnterpriseAuthProviderOptions
+{
+ ///
+ /// Gets or sets the MCP client ID used for the JWT Bearer grant 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 auth server requires client authentication.
+ ///
+ public string? ClientSecret { get; set; }
+
+ ///
+ /// Gets or sets the scopes to request from the MCP authorization server (space-separated).
+ ///
+ public string? Scope { get; set; }
+
+ ///
+ /// Gets or sets the assertion callback that obtains a JWT Authorization Grant (JAG).
+ ///
+ ///
+ ///
+ /// This callback is invoked during the Enterprise Auth flow, after the MCP resource and
+ /// authorization server URLs have been discovered. It receives a
+ /// with these URLs and should return the JAG JWT string.
+ ///
+ ///
+ /// A typical implementation calls
+ /// or to perform the RFC 8693 token
+ /// exchange at the enterprise Identity Provider.
+ ///
+ ///
+ public required Func> AssertionCallback { get; set; }
+}
diff --git a/src/ModelContextProtocol.Core/McpJsonUtilities.cs b/src/ModelContextProtocol.Core/McpJsonUtilities.cs
index abb6d29df..87086892f 100644
--- a/src/ModelContextProtocol.Core/McpJsonUtilities.cs
+++ b/src/ModelContextProtocol.Core/McpJsonUtilities.cs
@@ -187,6 +187,11 @@ internal static bool IsValidMcpToolSchema(JsonElement element)
[JsonSerializable(typeof(DynamicClientRegistrationRequest))]
[JsonSerializable(typeof(DynamicClientRegistrationResponse))]
+ // Enterprise Managed Authorization (SEP-990) types
+ [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.Tests/EnterpriseAuthTests.cs b/tests/ModelContextProtocol.Tests/EnterpriseAuthTests.cs
new file mode 100644
index 000000000..29ba78396
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/EnterpriseAuthTests.cs
@@ -0,0 +1,894 @@
+using System.Net;
+using System.Text.Json.Nodes;
+using ModelContextProtocol.Authentication;
+
+namespace ModelContextProtocol.Tests;
+
+public sealed class EnterpriseAuthTests : IDisposable
+{
+ private readonly MockHttpMessageHandler _mockHandler;
+ private readonly HttpClient _httpClient;
+
+ public EnterpriseAuthTests()
+ {
+ _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"] = EnterpriseAuth.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 EnterpriseAuth.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"] = EnterpriseAuth.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 EnterpriseAuth.RequestJwtAuthorizationGrantAsync(options, TestContext.Current.CancellationToken);
+
+ Assert.NotNull(capturedBody);
+ var formParams = ParseFormData(capturedBody!);
+ Assert.Equal(EnterpriseAuth.GrantTypeTokenExchange, formParams["grant_type"]);
+ Assert.Equal(EnterpriseAuth.TokenTypeIdJag, formParams["requested_token_type"]);
+ Assert.Equal("my-id-token", formParams["subject_token"]);
+ Assert.Equal(EnterpriseAuth.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"] = EnterpriseAuth.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 EnterpriseAuth.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_ThrowsEnterpriseAuthException()
+ {
+ _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(
+ () => EnterpriseAuth.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(
+ () => EnterpriseAuth.RequestJwtAuthorizationGrantAsync(options, TestContext.Current.CancellationToken));
+ Assert.Contains("issued_token_type", ex.Message);
+ Assert.Contains(EnterpriseAuth.TokenTypeIdJag, ex.Message);
+ }
+
+ [Fact]
+ public async Task RequestJwtAuthorizationGrantAsync_WrongTokenType_ThrowsException()
+ {
+ _mockHandler.Handler = _ => JsonResponse(HttpStatusCode.OK, new JsonObject
+ {
+ ["access_token"] = "test-jag",
+ ["issued_token_type"] = EnterpriseAuth.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(
+ () => EnterpriseAuth.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"] = EnterpriseAuth.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 EnterpriseAuth.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"] = EnterpriseAuth.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(
+ () => EnterpriseAuth.RequestJwtAuthorizationGrantAsync(options, TestContext.Current.CancellationToken));
+ Assert.Contains("access_token", ex.Message);
+ }
+
+ [Fact]
+ public async Task RequestJwtAuthorizationGrantAsync_NullOptions_ThrowsArgumentNullException()
+ {
+ await Assert.ThrowsAsync(
+ () => EnterpriseAuth.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(
+ () => EnterpriseAuth.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 EnterpriseAuth.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 EnterpriseAuth.ExchangeJwtBearerGrantAsync(options, TestContext.Current.CancellationToken);
+
+ Assert.NotNull(capturedBody);
+ var formParams = ParseFormData(capturedBody!);
+ Assert.Equal(EnterpriseAuth.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_ThrowsEnterpriseAuthException()
+ {
+ _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(
+ () => EnterpriseAuth.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(
+ () => EnterpriseAuth.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 EnterpriseAuth.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(
+ () => EnterpriseAuth.ExchangeJwtBearerGrantAsync(options, TestContext.Current.CancellationToken));
+ Assert.Contains("access_token", ex.Message);
+ }
+
+ [Fact]
+ public async Task ExchangeJwtBearerGrantAsync_NullOptions_ThrowsArgumentNullException()
+ {
+ await Assert.ThrowsAsync(
+ () => EnterpriseAuth.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"] = EnterpriseAuth.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 EnterpriseAuth.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"] = EnterpriseAuth.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 EnterpriseAuth.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(
+ () => EnterpriseAuth.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(
+ () => EnterpriseAuth.DiscoverAndRequestJwtAuthorizationGrantAsync(options, TestContext.Current.CancellationToken));
+ }
+
+ #endregion
+
+ #region EnterpriseAuthProvider Tests
+
+ [Fact]
+ public async Task EnterpriseAuthProvider_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("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 EnterpriseAuthProvider(
+ new EnterpriseAuthProviderOptions
+ {
+ ClientId = "mcp-client-id",
+ AssertionCallback = (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-jag-assertion");
+ },
+ },
+ _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 EnterpriseAuthProvider_CachesTokens()
+ {
+ var tokenCallCount = 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",
+ });
+ }
+
+ tokenCallCount++;
+ return JsonResponse(HttpStatusCode.OK, new JsonObject
+ {
+ ["access_token"] = "cached-token",
+ ["token_type"] = "Bearer",
+ ["expires_in"] = 3600,
+ });
+ };
+
+ var assertionCallCount = 0;
+ var provider = new EnterpriseAuthProvider(
+ new EnterpriseAuthProviderOptions
+ {
+ ClientId = "client-id",
+ AssertionCallback = (_, _) =>
+ {
+ assertionCallCount++;
+ return Task.FromResult("mock-jag");
+ },
+ },
+ _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, assertionCallCount);
+ Assert.Equal(1, tokenCallCount);
+ }
+
+ [Fact]
+ public async Task EnterpriseAuthProvider_InvalidateCache_ForcesRefresh()
+ {
+ var assertionCallCount = 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",
+ });
+ }
+
+ return JsonResponse(HttpStatusCode.OK, new JsonObject
+ {
+ ["access_token"] = $"token-{assertionCallCount}",
+ ["token_type"] = "Bearer",
+ ["expires_in"] = 3600,
+ });
+ };
+
+ var provider = new EnterpriseAuthProvider(
+ new EnterpriseAuthProviderOptions
+ {
+ ClientId = "client-id",
+ AssertionCallback = (_, _) =>
+ {
+ assertionCallCount++;
+ return Task.FromResult("mock-jag");
+ },
+ },
+ _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, assertionCallCount);
+ }
+
+ [Fact]
+ public async Task EnterpriseAuthProvider_AssertionCallbackReturnsEmpty_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 EnterpriseAuthProvider(
+ new EnterpriseAuthProviderOptions
+ {
+ ClientId = "client-id",
+ AssertionCallback = (_, _) => 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 EnterpriseAuthProvider_NullOptions_ThrowsArgumentNullException()
+ {
+ Assert.Throws(() => new EnterpriseAuthProvider(null!));
+ }
+
+ [Fact]
+ public void EnterpriseAuthProvider_MissingClientId_ThrowsArgumentException()
+ {
+ Assert.Throws(() => new EnterpriseAuthProvider(
+ new EnterpriseAuthProviderOptions
+ {
+ ClientId = "",
+ AssertionCallback = (_, _) => Task.FromResult("test"),
+ }));
+ }
+
+ [Fact]
+ public void EnterpriseAuthProvider_MissingAssertionCallback_ThrowsArgumentException()
+ {
+ Assert.Throws(() => new EnterpriseAuthProvider(
+ new EnterpriseAuthProviderOptions
+ {
+ ClientId = "client-id",
+ AssertionCallback = null!,
+ }));
+ }
+
+ #endregion
+
+ #region EnterpriseAuthException Tests
+
+ [Fact]
+ public void EnterpriseAuthException_WithErrorCodeAndDescription_FormatsMessage()
+ {
+ var ex = new EnterpriseAuthException("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 EnterpriseAuthException_WithErrorUri_StoresIt()
+ {
+ var ex = new EnterpriseAuthException("msg", "error", "desc", "https://docs.example.com/error");
+
+ Assert.Equal("https://docs.example.com/error", ex.ErrorUri);
+ }
+
+ [Fact]
+ public void EnterpriseAuthException_WithoutErrorDetails_PlainMessage()
+ {
+ var ex = new EnterpriseAuthException("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", EnterpriseAuth.GrantTypeTokenExchange);
+ Assert.Equal("urn:ietf:params:oauth:grant-type:jwt-bearer", EnterpriseAuth.GrantTypeJwtBearer);
+ Assert.Equal("urn:ietf:params:oauth:token-type:id_token", EnterpriseAuth.TokenTypeIdToken);
+ Assert.Equal("urn:ietf:params:oauth:token-type:saml2", EnterpriseAuth.TokenTypeSaml2);
+ Assert.Equal("urn:ietf:params:oauth:token-type:id-jag", EnterpriseAuth.TokenTypeIdJag);
+ Assert.Equal("N_A", EnterpriseAuth.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
+}