Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 `CrossApplicationAccessProvider`. 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 `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.
296 changes: 296 additions & 0 deletions src/ModelContextProtocol.Core/Authentication/CrossApplicationAccess.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 CrossApplicationAccess
{
#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 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)

/// <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 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"];

/// <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 CrossApplicationAccessException($"Failed to discover authorization server metadata for: {issuerUrl}");
}

#endregion
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace ModelContextProtocol.Authentication;

/// <summary>
/// Context provided to the <see cref="CrossApplicationAccessIdTokenCallback"/> for a Cross-Application Access
/// authorization flow. Contains the URLs discovered during the OAuth flow needed for the token exchange step.
/// </summary>
public sealed class CrossApplicationAccessContext
{
/// <summary>
/// Gets the MCP resource server URL (i.e., the <c>resource</c> parameter for token exchange).
/// This is the URL of the MCP server being accessed.
/// </summary>
public required Uri ResourceUrl { get; init; }

/// <summary>
/// Gets the MCP authorization server URL (i.e., the <c>audience</c> parameter for token exchange).
/// This is the URL of the authorization server protecting the MCP resource.
/// </summary>
public required Uri AuthorizationServerUrl { get; init; }
}
Loading