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 IdentityAssertionGrant { #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 IdentityAssertionGrantException( $"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 IdentityAssertionGrantException("Failed to parse token exchange response."); ex.Data["ResponseBody"] = responseBody; throw ex; } if (string.IsNullOrEmpty(response.AccessToken)) { throw new IdentityAssertionGrantException("Token exchange response missing required field: access_token"); } if (!string.Equals(response.IssuedTokenType, TokenTypeIdJag, StringComparison.Ordinal)) { throw new IdentityAssertionGrantException( $"Token exchange response issued_token_type must be '{TokenTypeIdJag}', got '{response.IssuedTokenType}'."); } if (!string.Equals(response.TokenType, TokenTypeNotApplicable, StringComparison.Ordinal)) { throw new IdentityAssertionGrantException( $"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 IdentityAssertionGrantException( $"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 IdentityAssertionGrantException("Failed to parse JWT bearer grant response."); ex.Data["ResponseBody"] = responseBody; throw ex; } if (string.IsNullOrEmpty(response.AccessToken)) { throw new IdentityAssertionGrantException("JWT bearer grant response missing required field: access_token"); } if (string.IsNullOrEmpty(response.TokenType)) { throw new IdentityAssertionGrantException("JWT bearer grant response missing required field: token_type"); } if (!string.Equals(response.TokenType, "bearer", StringComparison.OrdinalIgnoreCase)) { throw new IdentityAssertionGrantException( $"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 IdentityAssertionGrantException($"Failed to discover authorization server metadata for: {issuerUrl}"); } #endregion }