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