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