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 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);
///
///
public sealed class IdentityAssertionGrantProvider
{
private readonly IdentityAssertionGrantProviderOptions _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 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() ?? 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 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;
}
///
/// 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 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;
}
}