From c9450192599425932e338faa2ca541e068452e91 Mon Sep 17 00:00:00 2001 From: Aniket Date: Wed, 18 Feb 2026 11:49:40 +0530 Subject: [PATCH] feat: Add Enterprise Managed Authorization (SEP-990) support --- README.md | 61 ++ .../Authentication/EnterpriseAuth.cs | 683 +++++++++++++ .../Authentication/EnterpriseAuthProvider.cs | 232 +++++ .../McpJsonUtilities.cs | 5 + .../EnterpriseAuthTests.cs | 894 ++++++++++++++++++ 5 files changed, 1875 insertions(+) create mode 100644 src/ModelContextProtocol.Core/Authentication/EnterpriseAuth.cs create mode 100644 src/ModelContextProtocol.Core/Authentication/EnterpriseAuthProvider.cs create mode 100644 tests/ModelContextProtocol.Tests/EnterpriseAuthTests.cs 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 +}