csharp-sdk/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProvider.cs

207 lines
8.8 KiB
C#

using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace ModelContextProtocol.Authentication;
/// <summary>
/// Provides Cross-Application Access authorization as a standalone, non-interactive provider
/// that can be used alongside the MCP client's OAuth infrastructure.
/// </summary>
/// <remarks>
/// <para>
/// This provider implements the full Identity Assertion Authorization Grant flow as specified at
/// <see href="https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/enterprise-managed-authorization.mdx"/>:
/// </para>
/// <list type="number">
/// <item><description>
/// The <see cref="IdentityAssertionGrantProviderOptions.IdTokenCallback"/> is called to obtain an OIDC ID token.
/// It receives a <see cref="IdentityAssertionGrantContext"/> with the discovered resource and authorization
/// server URLs.
/// </description></item>
/// <item><description>
/// The provider performs the RFC 8693 token exchange at the enterprise Identity Provider
/// (using the configured <c>IdpTokenEndpoint</c> or discovered from <c>IdpUrl</c>),
/// exchanging the ID token for a JWT Authorization Grant (JAG).
/// </description></item>
/// <item><description>
/// The JAG is then exchanged for an access token at the MCP Server's authorization server
/// via the RFC 7523 JWT Bearer grant.
/// </description></item>
/// </list>
/// </remarks>
/// <example>
/// <code>
/// var provider = new IdentityAssertionGrantProvider(
/// new IdentityAssertionGrantProviderOptions
/// {
/// 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);
/// </code>
/// </example>
public sealed class IdentityAssertionGrantProvider
{
private readonly IdentityAssertionGrantProviderOptions _options;
private readonly HttpClient _httpClient;
private readonly ILogger _logger;
private TokenContainer? _cachedTokens;
/// <summary>
/// Initializes a new instance of the <see cref="IdentityAssertionGrantProvider"/> class.
/// </summary>
/// <param name="options">Configuration for the Cross-Application Access provider.</param>
/// <param name="httpClient">
/// The HTTP client to use for token exchange requests. The caller is responsible for the lifetime of this instance.
/// </param>
/// <param name="loggerFactory">Optional logger factory.</param>
/// <exception cref="ArgumentNullException"><paramref name="options"/> or <paramref name="httpClient"/> is null.</exception>
/// <exception cref="ArgumentException">Required option values are missing.</exception>
public IdentityAssertionGrantProvider(
IdentityAssertionGrantProviderOptions 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<IdentityAssertionGrantProvider>() ?? NullLogger.Instance;
}
/// <summary>
/// Performs the full Cross-Application Access flow to obtain an access token for the given MCP resource.
/// </summary>
/// <param name="resourceUrl">The MCP resource server URL.</param>
/// <param name="authorizationServerUrl">The MCP authorization server URL.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A <see cref="TokenContainer"/> containing the access token.</returns>
/// <exception cref="IdentityAssertionGrantException">Thrown when any step of the flow fails.</exception>
public async Task<TokenContainer> 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 IdentityAssertionGrant.DiscoverAuthServerMetadataAsync(
authorizationServerUrl, _httpClient, cancellationToken).ConfigureAwait(false);
var mcpTokenEndpoint = mcpAuthMetadata.TokenEndpoint?.ToString()
?? throw new IdentityAssertionGrantException(
$"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 IdentityAssertionGrantContext
{
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 IdentityAssertionGrantException("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 IdentityAssertionGrant.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 IdentityAssertionGrant.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;
}
/// <summary>
/// Clears any cached tokens, forcing a fresh token exchange on the next call to <see cref="GetAccessTokenAsync"/>.
/// </summary>
public void InvalidateCache()
{
_cachedTokens = null;
}
private string? _resolvedIdpTokenEndpoint;
private async Task<string> 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 IdentityAssertionGrant.DiscoverAuthServerMetadataAsync(
new Uri(_options.IdpUrl!), _httpClient, cancellationToken).ConfigureAwait(false);
var resolved = idpMetadata.TokenEndpoint?.ToString()
?? throw new IdentityAssertionGrantException(
$"IdP metadata discovery for {_options.IdpUrl} did not return a token_endpoint.");
_resolvedIdpTokenEndpoint = resolved;
return resolved;
}
}