Skip to content
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `IdentityAssertionGrantProvider`. See the [Cross-Application Access](docs/concepts/transports/transports.md#cross-application-access) section in the transport docs for full usage details.

Comment thread
aniket-okta marked this conversation as resolved.
## License

This project is licensed under the [Apache License 2.0](LICENSE).
39 changes: 39 additions & 0 deletions docs/concepts/transports/transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `IdentityAssertionGrantProvider`. 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 IdentityAssertionGrantProvider(
new IdentityAssertionGrantProviderOptions
{
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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
namespace ModelContextProtocol.Authentication;

/// <summary>
/// Options for exchanging a JWT Authorization Grant for an access token via RFC 7523.
/// </summary>
internal sealed class ExchangeJwtBearerGrantOptions
{
/// <summary>
/// Gets or sets the MCP Server's authorization server token endpoint URL.
/// </summary>
public required string TokenEndpoint { get; set; }

/// <summary>
/// Gets or sets the JWT Authorization Grant (JAG) assertion obtained from token exchange.
/// </summary>
public required string Assertion { get; set; }

/// <summary>
/// Gets or sets the client ID for authentication with the MCP authorization server.
/// </summary>
public required string ClientId { get; set; }

/// <summary>
/// Gets or sets the client secret for authentication with the MCP authorization server. Optional.
/// </summary>
public string? ClientSecret { get; set; }

/// <summary>
/// Gets or sets the scopes to request (space-separated). Optional.
/// </summary>
public string? Scope { get; set; }
}
296 changes: 296 additions & 0 deletions src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrant.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
using System.Net.Http.Headers;
using System.Text.Json;

namespace ModelContextProtocol.Authentication;

/// <summary>
/// Provides internal utilities for the Cross-Application Access authorization flow.
/// </summary>
/// <remarks>
/// Implements the Enterprise Managed Authorization flow as specified at
/// <see href="https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/enterprise-managed-authorization.mdx"/>.
/// </remarks>
internal static class IdentityAssertionGrant
{
#region Constants

/// <summary>Grant type URN for RFC 8693 token exchange.</summary>
public const string GrantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange";

/// <summary>Grant type URN for RFC 7523 JWT Bearer authorization grant.</summary>
public const string GrantTypeJwtBearer = "urn:ietf:params:oauth:grant-type:jwt-bearer";

/// <summary>Token type URN for OpenID Connect ID Tokens (RFC 8693).</summary>
public const string TokenTypeIdToken = "urn:ietf:params:oauth:token-type:id_token";

/// <summary>Token type URN for SAML 2.0 assertions (RFC 8693).</summary>
public const string TokenTypeSaml2 = "urn:ietf:params:oauth:token-type:saml2";

/// <summary>
/// Token type URN for Identity Assertion JWT Authorization Grants.
/// As specified at
/// <see href="https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/enterprise-managed-authorization.mdx"/>.
/// </summary>
public const string TokenTypeIdJag = "urn:ietf:params:oauth:token-type:id-jag";

/// <summary>
/// The expected value for <c>token_type</c> 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".
/// </summary>
public const string TokenTypeNotApplicable = "N_A";

#endregion

#region Token Exchange (RFC 8693)

/// <summary>
/// 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.
/// </summary>
public static async Task<string> 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<string, string>
{
["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)

/// <summary>
/// Exchanges a JWT Authorization Grant (JAG) for an access token at an MCP Server's authorization server
/// using the JWT Bearer grant (RFC 7523).
/// </summary>
public static async Task<TokenContainer> 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<string, string>
{
["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"];

/// <summary>
/// Discovers authorization server metadata from the well-known endpoints.
/// </summary>
internal static async Task<AuthorizationServerMetadata> 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
}
Loading