This commit is contained in:
commit
06e3604dd1
19 changed files with 1695 additions and 2 deletions
|
|
@ -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.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the [Apache License 2.0](LICENSE).
|
||||
|
|
|
|||
|
|
@ -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 (ID-JAG) 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.
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
namespace ModelContextProtocol.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Context provided to the <see cref="IdentityAssertionGrantIdTokenCallback"/> 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 IdentityAssertionGrantContext
|
||||
{
|
||||
/// <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; }
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
namespace ModelContextProtocol.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an error that occurred during a Cross-Application Access authorization operation
|
||||
/// (token exchange per RFC 8693, and JWT bearer grant per RFC 7523).
|
||||
/// </summary>
|
||||
public sealed class IdentityAssertionGrantException : Exception
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the OAuth error code, if available (e.g., "invalid_request", "invalid_grant").
|
||||
/// </summary>
|
||||
public string? ErrorCode { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the human-readable error description from the OAuth error response.
|
||||
/// </summary>
|
||||
public string? ErrorDescription { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the URI identifying a human-readable web page with error information.
|
||||
/// </summary>
|
||||
public string? ErrorUri { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="IdentityAssertionGrantException"/> class.
|
||||
/// </summary>
|
||||
/// <param name="message">The error message.</param>
|
||||
/// <param name="errorCode">The OAuth error code.</param>
|
||||
/// <param name="errorDescription">The human-readable error description.</param>
|
||||
/// <param name="errorUri">The error URI.</param>
|
||||
public IdentityAssertionGrantException(string message, string? errorCode = null, string? errorDescription = null, string? errorUri = null)
|
||||
: base(FormatMessage(message, errorCode, errorDescription))
|
||||
{
|
||||
ErrorCode = errorCode;
|
||||
ErrorDescription = errorDescription;
|
||||
ErrorUri = errorUri;
|
||||
}
|
||||
|
||||
private static string FormatMessage(string message, string? errorCode, string? errorDescription)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(errorCode))
|
||||
{
|
||||
message = $"{message} Error: {errorCode}";
|
||||
if (!string.IsNullOrEmpty(errorDescription))
|
||||
{
|
||||
message = $"{message} ({errorDescription})";
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
namespace ModelContextProtocol.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a method that returns an OIDC ID token for use in a Cross-Application Access authorization flow.
|
||||
/// </summary>
|
||||
/// <param name="context">
|
||||
/// Context containing the MCP resource and authorization server URLs discovered during the OAuth flow.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>
|
||||
/// A task that represents the asynchronous operation. The task result contains the OIDC ID token string
|
||||
/// obtained from the enterprise Identity Provider (e.g., via SSO login). The provider will then use this
|
||||
/// ID token to perform the RFC 8693 token exchange to obtain a JWT Authorization Grant.
|
||||
/// </returns>
|
||||
public delegate Task<string> IdentityAssertionGrantIdTokenCallback(
|
||||
IdentityAssertionGrantContext context,
|
||||
CancellationToken cancellationToken);
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
namespace ModelContextProtocol.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the <see cref="IdentityAssertionGrantProvider"/>.
|
||||
/// </summary>
|
||||
public sealed class IdentityAssertionGrantProviderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the MCP client ID used for the JWT Bearer grant (RFC 7523) at the MCP authorization server.
|
||||
/// </summary>
|
||||
public required string ClientId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the MCP client secret used for the JWT Bearer grant at the MCP authorization server.
|
||||
/// Optional; only required if the MCP authorization server requires client authentication.
|
||||
/// </summary>
|
||||
public string? ClientSecret { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the scopes to request from the MCP authorization server (space-separated). Optional.
|
||||
/// </summary>
|
||||
public string? Scope { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the enterprise Identity Provider base URL for OAuth/OIDC metadata discovery.
|
||||
/// Used to discover <c>IdpTokenEndpoint</c> automatically when <see cref="IdpTokenEndpoint"/> is not set.
|
||||
/// Either this or <see cref="IdpTokenEndpoint"/> must be provided.
|
||||
/// </summary>
|
||||
public string? IdpUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the enterprise Identity Provider token endpoint URL for RFC 8693 token exchange.
|
||||
/// When provided, skips IdP metadata discovery. Either this or <see cref="IdpUrl"/> must be provided.
|
||||
/// </summary>
|
||||
public string? IdpTokenEndpoint { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the client ID for authentication with the enterprise Identity Provider (RFC 8693 token exchange).
|
||||
/// </summary>
|
||||
public required string IdpClientId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the client secret for authentication with the enterprise Identity Provider. Optional.
|
||||
/// </summary>
|
||||
public string? IdpClientSecret { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the scopes to request from the enterprise Identity Provider (space-separated). Optional.
|
||||
/// </summary>
|
||||
public string? IdpScope { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the callback that supplies the OIDC ID token for the Cross-Application Access flow.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This callback is invoked after the MCP resource and authorization server URLs have been discovered.
|
||||
/// It receives a <see cref="IdentityAssertionGrantContext"/> with these URLs and should return the
|
||||
/// OIDC ID token string obtained from the enterprise Identity Provider (e.g., from an SSO login session).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The provider will use the returned ID token to internally perform the RFC 8693 token exchange at the
|
||||
/// configured IdP, obtaining a JWT Authorization Grant, which is then exchanged for an access token at
|
||||
/// the MCP authorization server via RFC 7523.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public required IdentityAssertionGrantIdTokenCallback IdTokenCallback { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
namespace ModelContextProtocol.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the response from an RFC 8693 Token Exchange for the JAG flow.
|
||||
/// Contains the JWT Authorization Grant in the <see cref="AccessToken"/> field.
|
||||
/// </summary>
|
||||
internal sealed class JagTokenExchangeResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the issued JAG. Despite the name "access_token" (required by RFC 8693),
|
||||
/// this contains a JAG JWT, not an OAuth access token.
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonPropertyName("access_token")]
|
||||
public string AccessToken { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the type of the security token issued.
|
||||
/// This MUST be <see cref="IdentityAssertionGrant.TokenTypeIdJag"/>.
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonPropertyName("issued_token_type")]
|
||||
public string IssuedTokenType { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the token type. This MUST be "N_A" per RFC 8693 §2.2.1.
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonPropertyName("token_type")]
|
||||
public string TokenType { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the scope of the issued token, if different from the request.
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonPropertyName("scope")]
|
||||
public string? Scope { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the lifetime in seconds of the issued token.
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonPropertyName("expires_in")]
|
||||
public int? ExpiresIn { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
namespace ModelContextProtocol.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the response from a JWT Bearer grant (RFC 7523) access token request.
|
||||
/// </summary>
|
||||
internal sealed class JwtBearerAccessTokenResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the OAuth access token.
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonPropertyName("access_token")]
|
||||
public string AccessToken { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the token type. This should be "Bearer".
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonPropertyName("token_type")]
|
||||
public string TokenType { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the lifetime in seconds of the access token.
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonPropertyName("expires_in")]
|
||||
public int? ExpiresIn { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the refresh token.
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonPropertyName("refresh_token")]
|
||||
public string? RefreshToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the scope of the access token.
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonPropertyName("scope")]
|
||||
public string? Scope { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
namespace ModelContextProtocol.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an OAuth error response per RFC 6749 Section 5.2.
|
||||
/// Used for both token exchange and JWT bearer grant error responses.
|
||||
/// </summary>
|
||||
internal sealed class OAuthErrorResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the error code.
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonPropertyName("error")]
|
||||
public string? Error { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the human-readable error description.
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonPropertyName("error_description")]
|
||||
public string? ErrorDescription { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the URI identifying a human-readable web page with error information.
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonPropertyName("error_uri")]
|
||||
public string? ErrorUri { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
namespace ModelContextProtocol.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Options for requesting a JWT Authorization Grant from an Identity Provider via RFC 8693 Token Exchange.
|
||||
/// </summary>
|
||||
internal sealed class RequestJwtAuthGrantOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the IDP's token endpoint URL.
|
||||
/// </summary>
|
||||
public required string TokenEndpoint { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the MCP authorization server URL (used as the <c>audience</c> parameter).
|
||||
/// </summary>
|
||||
public required string Audience { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the MCP resource server URL (used as the <c>resource</c> parameter).
|
||||
/// </summary>
|
||||
public required string Resource { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the OIDC ID token to exchange.
|
||||
/// </summary>
|
||||
public required string IdToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the client ID for authentication with the IDP.
|
||||
/// </summary>
|
||||
public required string ClientId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the client secret for authentication with the IDP. Optional.
|
||||
/// </summary>
|
||||
public string? ClientSecret { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the scopes to request (space-separated). Optional.
|
||||
/// </summary>
|
||||
public string? Scope { get; set; }
|
||||
}
|
||||
|
|
@ -187,6 +187,12 @@ public static partial class McpJsonUtilities
|
|||
[JsonSerializable(typeof(DynamicClientRegistrationRequest))]
|
||||
[JsonSerializable(typeof(DynamicClientRegistrationResponse))]
|
||||
|
||||
// For Enterprise Managed Authorization flow as specified at
|
||||
// https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/enterprise-managed-authorization.mdx
|
||||
[JsonSerializable(typeof(JagTokenExchangeResponse))]
|
||||
[JsonSerializable(typeof(JwtBearerAccessTokenResponse))]
|
||||
[JsonSerializable(typeof(OAuthErrorResponse))]
|
||||
|
||||
// Primitive types for use in consuming AIFunctions
|
||||
[JsonSerializable(typeof(string))]
|
||||
[JsonSerializable(typeof(byte))]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,167 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ModelContextProtocol.Authentication;
|
||||
using ModelContextProtocol.Client;
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
namespace ModelContextProtocol.AspNetCore.Tests.OAuth;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for Cross-Application Access authorization using the in-memory
|
||||
/// test OAuth server as a stand-in for both the enterprise Identity Provider (IdP) and
|
||||
/// the MCP Authorization Server (AS).
|
||||
///
|
||||
/// Flow exercised:
|
||||
/// 1. <see cref="IdentityAssertionGrantProvider.GetAccessTokenAsync"/> discovers the MCP AS
|
||||
/// metadata and calls the ID token callback.
|
||||
/// 2. The provider performs RFC 8693 token exchange at <c>/idp/token</c> on the test OAuth server
|
||||
/// (ID token → JAG).
|
||||
/// 3. The provider exchanges the JAG for an access token at <c>/token</c>
|
||||
/// (RFC 7523 JWT-bearer grant: JAG → access token).
|
||||
/// 4. The access token is passed to the MCP client transport and used to authenticate
|
||||
/// against the protected MCP server.
|
||||
/// </summary>
|
||||
public class IdentityAssertionGrantIntegrationTests : OAuthTestBase
|
||||
{
|
||||
public IdentityAssertionGrantIntegrationTests(ITestOutputHelper outputHelper)
|
||||
: base(outputHelper)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CanAuthenticate_WithIdentityAssertionGrantProvider()
|
||||
{
|
||||
// Enable Enterprise Managed Authorization endpoints on the test OAuth server.
|
||||
TestOAuthServer.EnterpriseSupportEnabled = true;
|
||||
|
||||
await using var app = await StartMcpServerAsync();
|
||||
|
||||
// Simulate the enterprise ID token that would normally come from the SSO login step.
|
||||
const string simulatedIdToken = "test-enterprise-sso-id-token";
|
||||
|
||||
// Create the provider with IdP config folded into options.
|
||||
// The ID token callback just returns the SSO ID token; the provider performs
|
||||
// RFC 8693 (ID token → JAG) and RFC 7523 (JAG → access token) internally.
|
||||
var provider = new IdentityAssertionGrantProvider(
|
||||
new IdentityAssertionGrantProviderOptions
|
||||
{
|
||||
ClientId = "enterprise-mcp-client",
|
||||
ClientSecret = "enterprise-mcp-secret",
|
||||
IdpTokenEndpoint = $"{OAuthServerUrl}/idp/token",
|
||||
IdpClientId = "enterprise-idp-client",
|
||||
IdpClientSecret = "enterprise-idp-secret",
|
||||
IdTokenCallback = (_, ct) => Task.FromResult(simulatedIdToken),
|
||||
},
|
||||
httpClient: HttpClient);
|
||||
|
||||
// Run the full Cross-Application Access flow: discover AS → get JAG → exchange for access token.
|
||||
var tokens = await provider.GetAccessTokenAsync(
|
||||
resourceUrl: new Uri(McpServerUrl),
|
||||
authorizationServerUrl: new Uri(OAuthServerUrl),
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(tokens.AccessToken);
|
||||
Assert.False(string.IsNullOrEmpty(tokens.AccessToken));
|
||||
Assert.Equal("bearer", tokens.TokenType, ignoreCase: true);
|
||||
|
||||
// Wire the obtained access token into an HTTP client that shares the same
|
||||
// in-memory Kestrel transport as the rest of the test fixture.
|
||||
var mcpHttpClient = new HttpClient(SocketsHttpHandler, disposeHandler: false);
|
||||
ConfigureHttpClient(mcpHttpClient);
|
||||
mcpHttpClient.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", tokens.AccessToken);
|
||||
|
||||
// Connect the MCP client using the enterprise access token — no interactive OAuth flow.
|
||||
await using var transport = new HttpClientTransport(
|
||||
new HttpClientTransportOptions { Endpoint = new Uri(McpServerUrl) },
|
||||
mcpHttpClient,
|
||||
LoggerFactory);
|
||||
|
||||
await using var client = await McpClient.CreateAsync(
|
||||
transport,
|
||||
loggerFactory: LoggerFactory,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// If we get here the MCP server accepted the enterprise access token.
|
||||
Assert.NotNull(client);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IdentityAssertionGrantProvider_ReturnsCachedToken_OnSecondCall()
|
||||
{
|
||||
TestOAuthServer.EnterpriseSupportEnabled = true;
|
||||
|
||||
await using var _ = await StartMcpServerAsync();
|
||||
|
||||
var idTokenCallCount = 0;
|
||||
|
||||
var provider = new IdentityAssertionGrantProvider(
|
||||
new IdentityAssertionGrantProviderOptions
|
||||
{
|
||||
ClientId = "enterprise-mcp-client",
|
||||
ClientSecret = "enterprise-mcp-secret",
|
||||
IdpTokenEndpoint = $"{OAuthServerUrl}/idp/token",
|
||||
IdpClientId = "enterprise-idp-client",
|
||||
IdpClientSecret = "enterprise-idp-secret",
|
||||
IdTokenCallback = (_, ct) =>
|
||||
{
|
||||
idTokenCallCount++;
|
||||
return Task.FromResult("test-sso-token");
|
||||
},
|
||||
},
|
||||
httpClient: HttpClient);
|
||||
|
||||
var tokens1 = await provider.GetAccessTokenAsync(
|
||||
new Uri(McpServerUrl), new Uri(OAuthServerUrl),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var tokens2 = await provider.GetAccessTokenAsync(
|
||||
new Uri(McpServerUrl), new Uri(OAuthServerUrl),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// The ID token callback (and therefore the IdP round-trip) should only fire once.
|
||||
Assert.Equal(1, idTokenCallCount);
|
||||
Assert.Equal(tokens1.AccessToken, tokens2.AccessToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IdentityAssertionGrantProvider_FetchesFreshToken_AfterInvalidateCache()
|
||||
{
|
||||
TestOAuthServer.EnterpriseSupportEnabled = true;
|
||||
|
||||
await using var _ = await StartMcpServerAsync();
|
||||
|
||||
var idTokenCallCount2 = 0;
|
||||
|
||||
var provider2 = new IdentityAssertionGrantProvider(
|
||||
new IdentityAssertionGrantProviderOptions
|
||||
{
|
||||
ClientId = "enterprise-mcp-client",
|
||||
ClientSecret = "enterprise-mcp-secret",
|
||||
IdpTokenEndpoint = $"{OAuthServerUrl}/idp/token",
|
||||
IdpClientId = "enterprise-idp-client",
|
||||
IdpClientSecret = "enterprise-idp-secret",
|
||||
IdTokenCallback = (_, ct) =>
|
||||
{
|
||||
idTokenCallCount2++;
|
||||
return Task.FromResult("test-sso-token");
|
||||
},
|
||||
},
|
||||
httpClient: HttpClient);
|
||||
|
||||
var tokens1 = await provider2.GetAccessTokenAsync(
|
||||
new Uri(McpServerUrl), new Uri(OAuthServerUrl),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Invalidate the cache to force a full re-exchange.
|
||||
provider2.InvalidateCache();
|
||||
|
||||
var tokens2 = await provider2.GetAccessTokenAsync(
|
||||
new Uri(McpServerUrl), new Uri(OAuthServerUrl),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// The IdP should have been called twice — once for each GetAccessTokenAsync after invalidation.
|
||||
Assert.Equal(2, idTokenCallCount2);
|
||||
// The tokens may or may not be identical depending on timing, but the flow ran again.
|
||||
Assert.NotNull(tokens2.AccessToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ModelContextProtocol.TestOAuthServer;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the token exchange response for the Identity Assertion JWT Authorization Grant (ID-JAG)
|
||||
/// per RFC 8693 / SEP-990.
|
||||
/// </summary>
|
||||
internal sealed class JagTokenExchangeResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the issued JWT Authorization Grant (JAG).
|
||||
/// Despite the field name "access_token" (required by RFC 8693), this contains a JAG JWT,
|
||||
/// not an OAuth access token.
|
||||
/// </summary>
|
||||
[JsonPropertyName("access_token")]
|
||||
public required string AccessToken { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the type of security token issued.
|
||||
/// For SEP-990, this MUST be "urn:ietf:params:oauth:token-type:id-jag".
|
||||
/// </summary>
|
||||
[JsonPropertyName("issued_token_type")]
|
||||
public required string IssuedTokenType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the token type.
|
||||
/// For SEP-990, this MUST be "N_A" per RFC 8693 §2.2.1 because the JAG is not an access token.
|
||||
/// </summary>
|
||||
[JsonPropertyName("token_type")]
|
||||
public required string TokenType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the lifetime in seconds of the issued JAG.
|
||||
/// </summary>
|
||||
[JsonPropertyName("expires_in")]
|
||||
public int? ExpiresIn { get; init; }
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ namespace ModelContextProtocol.TestOAuthServer;
|
|||
[JsonSerializable(typeof(OAuthServerMetadata))]
|
||||
[JsonSerializable(typeof(AuthorizationServerMetadata))]
|
||||
[JsonSerializable(typeof(TokenResponse))]
|
||||
[JsonSerializable(typeof(JagTokenExchangeResponse))]
|
||||
[JsonSerializable(typeof(JsonWebKeySet))]
|
||||
[JsonSerializable(typeof(JsonWebKey))]
|
||||
[JsonSerializable(typeof(TokenIntrospectionResponse))]
|
||||
|
|
|
|||
|
|
@ -57,6 +57,18 @@ public sealed class Program
|
|||
// Track if we've already issued an already-expired token for the CanAuthenticate_WithTokenRefresh test which uses the test-refresh-client registration.
|
||||
public bool HasRefreshedToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the server supports the Enterprise Managed
|
||||
/// Authorization (SEP-990) flow, including the IdP token-exchange endpoint and the
|
||||
/// JWT-bearer grant type at the token endpoint.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <c>true</c>, the server registers enterprise test clients and activates the
|
||||
/// <c>/idp/token</c> endpoint (RFC 8693 token exchange) and the
|
||||
/// <c>urn:ietf:params:oauth:grant-type:jwt-bearer</c> grant type (RFC 7523).
|
||||
/// </remarks>
|
||||
public bool EnterpriseSupportEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the authorization server
|
||||
/// advertises support for client ID metadata documents in its discovery
|
||||
|
|
@ -158,6 +170,25 @@ public sealed class Program
|
|||
RedirectUris = ["http://localhost:1179/callback"],
|
||||
};
|
||||
|
||||
// Enterprise Auth (SEP-990) clients.
|
||||
// The IdP client is used to authenticate calls to /idp/token (token exchange).
|
||||
// The MCP client is used to authenticate calls to /token (jwt-bearer grant).
|
||||
// Neither needs redirect URIs because neither uses the authorization code flow.
|
||||
_clients["enterprise-idp-client"] = new ClientInfo
|
||||
{
|
||||
ClientId = "enterprise-idp-client",
|
||||
ClientSecret = "enterprise-idp-secret",
|
||||
RequiresClientSecret = true,
|
||||
RedirectUris = [],
|
||||
};
|
||||
_clients["enterprise-mcp-client"] = new ClientInfo
|
||||
{
|
||||
ClientId = "enterprise-mcp-client",
|
||||
ClientSecret = "enterprise-mcp-secret",
|
||||
RequiresClientSecret = true,
|
||||
RedirectUris = [],
|
||||
};
|
||||
|
||||
// The MCP spec tells the client to use /.well-known/oauth-authorization-server but AddJwtBearer looks for
|
||||
// /.well-known/openid-configuration by default.
|
||||
//
|
||||
|
|
@ -348,10 +379,18 @@ public sealed class Program
|
|||
type: "https://tools.ietf.org/html/rfc6749#section-5.2");
|
||||
}
|
||||
|
||||
// Read grant type early so we can skip resource validation for grant types that
|
||||
// don't use the resource parameter (e.g. jwt-bearer where the resource is embedded
|
||||
// inside the JWT assertion itself).
|
||||
var grant_type = form["grant_type"].ToString();
|
||||
|
||||
// Validate resource in accordance with RFC 8707.
|
||||
// When ExpectResource is false, the resource parameter must be absent (legacy mode).
|
||||
// RFC 7523 JWT-bearer assertions carry the target resource inside the JWT itself,
|
||||
// so we skip the form-level resource check for that grant type.
|
||||
var resource = form["resource"].ToString();
|
||||
if (ExpectResource ? (string.IsNullOrEmpty(resource) || !ValidResources.Contains(resource)) : !string.IsNullOrEmpty(resource))
|
||||
if (grant_type != "urn:ietf:params:oauth:grant-type:jwt-bearer" &&
|
||||
(ExpectResource ? (string.IsNullOrEmpty(resource) || !ValidResources.Contains(resource)) : !string.IsNullOrEmpty(resource)))
|
||||
{
|
||||
return Results.BadRequest(new OAuthErrorResponse
|
||||
{
|
||||
|
|
@ -360,7 +399,6 @@ public sealed class Program
|
|||
});
|
||||
}
|
||||
|
||||
var grant_type = form["grant_type"].ToString();
|
||||
if (grant_type == "authorization_code")
|
||||
{
|
||||
var code = form["code"].ToString();
|
||||
|
|
@ -437,6 +475,45 @@ public sealed class Program
|
|||
HasRefreshedToken = true;
|
||||
return Results.Ok(response);
|
||||
}
|
||||
else if (grant_type == "urn:ietf:params:oauth:grant-type:jwt-bearer")
|
||||
{
|
||||
if (!EnterpriseSupportEnabled)
|
||||
{
|
||||
return Results.BadRequest(new OAuthErrorResponse
|
||||
{
|
||||
Error = "unsupported_grant_type",
|
||||
ErrorDescription = "JWT bearer grant is not enabled on this server."
|
||||
});
|
||||
}
|
||||
|
||||
var assertion = form["assertion"].ToString();
|
||||
if (string.IsNullOrEmpty(assertion))
|
||||
{
|
||||
return Results.BadRequest(new OAuthErrorResponse
|
||||
{
|
||||
Error = "invalid_request",
|
||||
ErrorDescription = "assertion is required for jwt-bearer grant"
|
||||
});
|
||||
}
|
||||
|
||||
// Extract the target resource from the JAG payload (set during /idp/token).
|
||||
// Fall back to ValidResources[0] so the token is still usable in tests even
|
||||
// if the resource claim is absent.
|
||||
var jagResource = ExtractJwtClaim(assertion, "resource");
|
||||
if (string.IsNullOrEmpty(jagResource) || !ValidResources.Contains(jagResource))
|
||||
{
|
||||
jagResource = ValidResources.Length > 0 ? ValidResources[0] : null;
|
||||
}
|
||||
|
||||
var resourceUri = jagResource is not null ? new Uri(jagResource) : null;
|
||||
var scope = form["scope"].ToString();
|
||||
var scopes = string.IsNullOrEmpty(scope)
|
||||
? ["mcp:tools"]
|
||||
: scope.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList();
|
||||
|
||||
var response = GenerateJwtTokenResponse(client.ClientId, scopes, resourceUri);
|
||||
return Results.Ok(response);
|
||||
}
|
||||
else
|
||||
{
|
||||
return Results.BadRequest(new OAuthErrorResponse
|
||||
|
|
@ -447,6 +524,77 @@ public sealed class Program
|
|||
}
|
||||
});
|
||||
|
||||
// IdP token-exchange endpoint (RFC 8693) for Enterprise Managed Authorization (SEP-990).
|
||||
// Exchanges an enterprise ID token (from SSO) for a JWT Authorization Grant (JAG)
|
||||
// that can subsequently be used at the /token endpoint via the jwt-bearer grant.
|
||||
app.MapPost("/idp/token", async (HttpContext context) =>
|
||||
{
|
||||
if (!EnterpriseSupportEnabled)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var form = await context.Request.ReadFormAsync();
|
||||
|
||||
// Authenticate the IdP client.
|
||||
var client = AuthenticateClient(context, form);
|
||||
if (client == null)
|
||||
{
|
||||
context.Response.StatusCode = 401;
|
||||
return Results.Problem(
|
||||
statusCode: 401,
|
||||
title: "Unauthorized",
|
||||
detail: "Invalid client credentials",
|
||||
type: "https://tools.ietf.org/html/rfc6749#section-5.2");
|
||||
}
|
||||
|
||||
var grantType = form["grant_type"].ToString();
|
||||
if (grantType != "urn:ietf:params:oauth:grant-type:token-exchange")
|
||||
{
|
||||
return Results.BadRequest(new OAuthErrorResponse
|
||||
{
|
||||
Error = "unsupported_grant_type",
|
||||
ErrorDescription = "Only urn:ietf:params:oauth:grant-type:token-exchange is supported on this endpoint."
|
||||
});
|
||||
}
|
||||
|
||||
var subjectToken = form["subject_token"].ToString();
|
||||
if (string.IsNullOrEmpty(subjectToken))
|
||||
{
|
||||
return Results.BadRequest(new OAuthErrorResponse
|
||||
{
|
||||
Error = "invalid_request",
|
||||
ErrorDescription = "subject_token is required."
|
||||
});
|
||||
}
|
||||
|
||||
var requestedTokenType = form["requested_token_type"].ToString();
|
||||
if (requestedTokenType != "urn:ietf:params:oauth:token-type:id-jag")
|
||||
{
|
||||
return Results.BadRequest(new OAuthErrorResponse
|
||||
{
|
||||
Error = "invalid_request",
|
||||
ErrorDescription = "requested_token_type must be urn:ietf:params:oauth:token-type:id-jag."
|
||||
});
|
||||
}
|
||||
|
||||
var audience = form["audience"].ToString();
|
||||
var resourceParam = form["resource"].ToString();
|
||||
|
||||
// Generate a JAG JWT signed with the server's RSA key.
|
||||
// The JAG encodes the intended audience (MCP AS) and resource (MCP server) so
|
||||
// the /token endpoint can later issue a correctly-scoped access token.
|
||||
var jag = GenerateJagJwt(audience, resourceParam);
|
||||
|
||||
return Results.Ok(new JagTokenExchangeResponse
|
||||
{
|
||||
AccessToken = jag,
|
||||
IssuedTokenType = "urn:ietf:params:oauth:token-type:id-jag",
|
||||
TokenType = "N_A",
|
||||
ExpiresIn = 300,
|
||||
});
|
||||
});
|
||||
|
||||
// Introspection endpoint
|
||||
app.MapPost("/introspect", async (HttpContext context) =>
|
||||
{
|
||||
|
|
@ -675,6 +823,70 @@ public sealed class Program
|
|||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a JWT Authorization Grant (JAG) signed with the server's RSA key.
|
||||
/// The JAG encodes the target audience (MCP AS URL) and the resource (MCP server URL).
|
||||
/// </summary>
|
||||
private string GenerateJagJwt(string audience, string resource)
|
||||
{
|
||||
var expiresIn = TimeSpan.FromMinutes(5);
|
||||
var issuedAt = DateTimeOffset.UtcNow;
|
||||
var expiresAt = issuedAt.Add(expiresIn);
|
||||
|
||||
var header = new Dictionary<string, string>
|
||||
{
|
||||
{ "alg", "RS256" },
|
||||
{ "typ", "JWT" },
|
||||
{ "kid", _keyId },
|
||||
};
|
||||
|
||||
var payload = new Dictionary<string, string>
|
||||
{
|
||||
{ "iss", _url },
|
||||
{ "sub", "enterprise-user" },
|
||||
{ "aud", audience },
|
||||
{ "resource", resource }, // carried through so /token can issue the right audience
|
||||
{ "jti", Guid.NewGuid().ToString() },
|
||||
{ "iat", issuedAt.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture) },
|
||||
{ "exp", expiresAt.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture) },
|
||||
};
|
||||
|
||||
var headerJson = System.Text.Json.JsonSerializer.Serialize(header, OAuthJsonContext.Default.DictionaryStringString);
|
||||
var payloadJson = System.Text.Json.JsonSerializer.Serialize(payload, OAuthJsonContext.Default.DictionaryStringString);
|
||||
|
||||
var headerBase64 = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(headerJson));
|
||||
var payloadBase64 = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(payloadJson));
|
||||
|
||||
var dataToSign = $"{headerBase64}.{payloadBase64}";
|
||||
var signature = _rsa.SignData(Encoding.UTF8.GetBytes(dataToSign), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
|
||||
return $"{headerBase64}.{payloadBase64}.{WebEncoders.Base64UrlEncode(signature)}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a JWT payload (without signature verification) and returns the value of
|
||||
/// <paramref name="claimName"/>, or <c>null</c> if the claim is absent or the JWT is malformed.
|
||||
/// </summary>
|
||||
private static string? ExtractJwtClaim(string jwt, string claimName)
|
||||
{
|
||||
var parts = jwt.Split('.');
|
||||
if (parts.Length < 2)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var payloadJson = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(parts[1]));
|
||||
var payload = System.Text.Json.JsonSerializer.Deserialize(payloadJson, OAuthJsonContext.Default.DictionaryStringString);
|
||||
return payload?.TryGetValue(claimName, out var value) == true ? value : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a random token for authorization code or refresh token.
|
||||
/// </summary>
|
||||
|
|
|
|||
389
tests/ModelContextProtocol.Tests/IdentityAssertionGrantTests.cs
Normal file
389
tests/ModelContextProtocol.Tests/IdentityAssertionGrantTests.cs
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
using System.Net;
|
||||
using System.Text.Json.Nodes;
|
||||
using ModelContextProtocol.Authentication;
|
||||
|
||||
namespace ModelContextProtocol.Tests;
|
||||
|
||||
public sealed class IdentityAssertionGrantTests : IDisposable
|
||||
{
|
||||
private readonly MockHttpMessageHandler _mockHandler;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public IdentityAssertionGrantTests()
|
||||
{
|
||||
_mockHandler = new MockHttpMessageHandler();
|
||||
_httpClient = new HttpClient(_mockHandler);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
_mockHandler.Dispose();
|
||||
}
|
||||
|
||||
#region IdentityAssertionGrantProvider Tests
|
||||
|
||||
[Fact]
|
||||
public async Task IdentityAssertionGrantProvider_FullFlow_ReturnsAccessToken()
|
||||
{
|
||||
_mockHandler.Handler = request =>
|
||||
{
|
||||
var url = request.RequestUri!.ToString();
|
||||
|
||||
if (url.Contains(".well-known/openid-configuration"))
|
||||
{
|
||||
return JsonResponse(HttpStatusCode.OK, new JsonObject
|
||||
{
|
||||
["issuer"] = "https://auth.mcp-server.example.com",
|
||||
["authorization_endpoint"] = "https://auth.mcp-server.example.com/authorize",
|
||||
["token_endpoint"] = "https://auth.mcp-server.example.com/token",
|
||||
});
|
||||
}
|
||||
|
||||
if (url.Contains("idp.example.com/token"))
|
||||
{
|
||||
return JsonResponse(HttpStatusCode.OK, new JsonObject
|
||||
{
|
||||
["access_token"] = "mock-jag-assertion",
|
||||
["issued_token_type"] = "urn:ietf:params:oauth:token-type:id-jag",
|
||||
["token_type"] = "N_A",
|
||||
});
|
||||
}
|
||||
|
||||
if (url.Contains("auth.mcp-server.example.com/token"))
|
||||
{
|
||||
return JsonResponse(HttpStatusCode.OK, new JsonObject
|
||||
{
|
||||
["access_token"] = "final-access-token",
|
||||
["token_type"] = "Bearer",
|
||||
["expires_in"] = 3600,
|
||||
});
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.NotFound);
|
||||
};
|
||||
|
||||
var provider = new IdentityAssertionGrantProvider(
|
||||
new IdentityAssertionGrantProviderOptions
|
||||
{
|
||||
ClientId = "mcp-client-id",
|
||||
IdpTokenEndpoint = "https://idp.example.com/token",
|
||||
IdpClientId = "idp-client-id",
|
||||
IdTokenCallback = (context, ct) =>
|
||||
{
|
||||
Assert.Equal(new Uri("https://mcp-server.example.com"), context.ResourceUrl);
|
||||
Assert.Equal(new Uri("https://auth.mcp-server.example.com"), context.AuthorizationServerUrl);
|
||||
return Task.FromResult("mock-id-token");
|
||||
},
|
||||
},
|
||||
_httpClient);
|
||||
|
||||
var tokens = await provider.GetAccessTokenAsync(
|
||||
resourceUrl: new Uri("https://mcp-server.example.com"),
|
||||
authorizationServerUrl: new Uri("https://auth.mcp-server.example.com"),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal("final-access-token", tokens.AccessToken);
|
||||
Assert.Equal("Bearer", tokens.TokenType);
|
||||
Assert.Equal(3600, tokens.ExpiresIn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IdentityAssertionGrantProvider_CachesTokens()
|
||||
{
|
||||
var mcpTokenCallCount = 0;
|
||||
_mockHandler.Handler = request =>
|
||||
{
|
||||
var url = request.RequestUri!.ToString();
|
||||
if (url.Contains(".well-known"))
|
||||
{
|
||||
return JsonResponse(HttpStatusCode.OK, new JsonObject
|
||||
{
|
||||
["authorization_endpoint"] = "https://auth.example.com/authorize",
|
||||
["token_endpoint"] = "https://auth.example.com/token",
|
||||
});
|
||||
}
|
||||
|
||||
if (url.Contains("idp.example.com"))
|
||||
{
|
||||
return JsonResponse(HttpStatusCode.OK, new JsonObject
|
||||
{
|
||||
["access_token"] = "mock-jag",
|
||||
["issued_token_type"] = "urn:ietf:params:oauth:token-type:id-jag",
|
||||
["token_type"] = "N_A",
|
||||
});
|
||||
}
|
||||
|
||||
mcpTokenCallCount++;
|
||||
return JsonResponse(HttpStatusCode.OK, new JsonObject
|
||||
{
|
||||
["access_token"] = "cached-token",
|
||||
["token_type"] = "Bearer",
|
||||
["expires_in"] = 3600,
|
||||
});
|
||||
};
|
||||
|
||||
var idTokenCallCount = 0;
|
||||
var provider = new IdentityAssertionGrantProvider(
|
||||
new IdentityAssertionGrantProviderOptions
|
||||
{
|
||||
ClientId = "client-id",
|
||||
IdpTokenEndpoint = "https://idp.example.com/token",
|
||||
IdpClientId = "idp-client-id",
|
||||
IdTokenCallback = (_, _) =>
|
||||
{
|
||||
idTokenCallCount++;
|
||||
return Task.FromResult("mock-id-token");
|
||||
},
|
||||
},
|
||||
_httpClient);
|
||||
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var firstTokens = await provider.GetAccessTokenAsync(
|
||||
new Uri("https://resource.example.com"),
|
||||
new Uri("https://auth.example.com"), ct);
|
||||
|
||||
var secondTokens = await provider.GetAccessTokenAsync(
|
||||
new Uri("https://resource.example.com"),
|
||||
new Uri("https://auth.example.com"), ct);
|
||||
|
||||
Assert.Same(firstTokens, secondTokens);
|
||||
Assert.Equal(1, idTokenCallCount);
|
||||
Assert.Equal(1, mcpTokenCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IdentityAssertionGrantProvider_InvalidateCache_ForcesRefresh()
|
||||
{
|
||||
var idTokenCallCount = 0;
|
||||
_mockHandler.Handler = request =>
|
||||
{
|
||||
var url = request.RequestUri!.ToString();
|
||||
if (url.Contains(".well-known"))
|
||||
{
|
||||
return JsonResponse(HttpStatusCode.OK, new JsonObject
|
||||
{
|
||||
["authorization_endpoint"] = "https://auth.example.com/authorize",
|
||||
["token_endpoint"] = "https://auth.example.com/token",
|
||||
});
|
||||
}
|
||||
|
||||
if (url.Contains("idp.example.com"))
|
||||
{
|
||||
return JsonResponse(HttpStatusCode.OK, new JsonObject
|
||||
{
|
||||
["access_token"] = "mock-jag",
|
||||
["issued_token_type"] = "urn:ietf:params:oauth:token-type:id-jag",
|
||||
["token_type"] = "N_A",
|
||||
});
|
||||
}
|
||||
|
||||
return JsonResponse(HttpStatusCode.OK, new JsonObject
|
||||
{
|
||||
["access_token"] = $"token-{idTokenCallCount}",
|
||||
["token_type"] = "Bearer",
|
||||
["expires_in"] = 3600,
|
||||
});
|
||||
};
|
||||
|
||||
var provider = new IdentityAssertionGrantProvider(
|
||||
new IdentityAssertionGrantProviderOptions
|
||||
{
|
||||
ClientId = "client-id",
|
||||
IdpTokenEndpoint = "https://idp.example.com/token",
|
||||
IdpClientId = "idp-client-id",
|
||||
IdTokenCallback = (_, _) =>
|
||||
{
|
||||
idTokenCallCount++;
|
||||
return Task.FromResult("mock-id-token");
|
||||
},
|
||||
},
|
||||
_httpClient);
|
||||
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
await provider.GetAccessTokenAsync(
|
||||
new Uri("https://resource.example.com"),
|
||||
new Uri("https://auth.example.com"), ct);
|
||||
|
||||
provider.InvalidateCache();
|
||||
|
||||
await provider.GetAccessTokenAsync(
|
||||
new Uri("https://resource.example.com"),
|
||||
new Uri("https://auth.example.com"), ct);
|
||||
|
||||
Assert.Equal(2, idTokenCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IdentityAssertionGrantProvider_IdTokenCallbackReturnsEmpty_ThrowsException()
|
||||
{
|
||||
_mockHandler.Handler = request =>
|
||||
{
|
||||
var url = request.RequestUri!.ToString();
|
||||
if (url.Contains(".well-known"))
|
||||
{
|
||||
return JsonResponse(HttpStatusCode.OK, new JsonObject
|
||||
{
|
||||
["authorization_endpoint"] = "https://auth.example.com/authorize",
|
||||
["token_endpoint"] = "https://auth.example.com/token",
|
||||
});
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.NotFound);
|
||||
};
|
||||
|
||||
var provider = new IdentityAssertionGrantProvider(
|
||||
new IdentityAssertionGrantProviderOptions
|
||||
{
|
||||
ClientId = "client-id",
|
||||
IdpTokenEndpoint = "https://idp.example.com/token",
|
||||
IdpClientId = "idp-client-id",
|
||||
IdTokenCallback = (_, _) => Task.FromResult(string.Empty),
|
||||
},
|
||||
_httpClient);
|
||||
|
||||
await Assert.ThrowsAsync<IdentityAssertionGrantException>(
|
||||
() => provider.GetAccessTokenAsync(
|
||||
new Uri("https://resource.example.com"),
|
||||
new Uri("https://auth.example.com"),
|
||||
TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IdentityAssertionGrantProvider_NullOptions_ThrowsArgumentNullException()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new IdentityAssertionGrantProvider(null!, _httpClient));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IdentityAssertionGrantProvider_NullHttpClient_ThrowsArgumentNullException()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new IdentityAssertionGrantProvider(
|
||||
new IdentityAssertionGrantProviderOptions
|
||||
{
|
||||
ClientId = "client-id",
|
||||
IdpTokenEndpoint = "https://idp.example.com/token",
|
||||
IdpClientId = "idp-client-id",
|
||||
IdTokenCallback = (_, _) => Task.FromResult("token"),
|
||||
},
|
||||
null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IdentityAssertionGrantProvider_MissingClientId_ThrowsArgumentException()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new IdentityAssertionGrantProvider(
|
||||
new IdentityAssertionGrantProviderOptions
|
||||
{
|
||||
ClientId = "",
|
||||
IdpTokenEndpoint = "https://idp.example.com/token",
|
||||
IdpClientId = "idp-client-id",
|
||||
IdTokenCallback = (_, _) => Task.FromResult("test"),
|
||||
},
|
||||
_httpClient));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IdentityAssertionGrantProvider_MissingIdTokenCallback_ThrowsArgumentNullException()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new IdentityAssertionGrantProvider(
|
||||
new IdentityAssertionGrantProviderOptions
|
||||
{
|
||||
ClientId = "client-id",
|
||||
IdpTokenEndpoint = "https://idp.example.com/token",
|
||||
IdpClientId = "idp-client-id",
|
||||
IdTokenCallback = null!,
|
||||
},
|
||||
_httpClient));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IdentityAssertionGrantProvider_MissingIdpConfig_ThrowsArgumentException()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new IdentityAssertionGrantProvider(
|
||||
new IdentityAssertionGrantProviderOptions
|
||||
{
|
||||
ClientId = "client-id",
|
||||
IdpClientId = "idp-client-id",
|
||||
// Neither IdpUrl nor IdpTokenEndpoint provided
|
||||
IdTokenCallback = (_, _) => Task.FromResult("test"),
|
||||
},
|
||||
_httpClient));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IdentityAssertionGrantException Tests
|
||||
|
||||
[Fact]
|
||||
public void IdentityAssertionGrantException_WithErrorCodeAndDescription_FormatsMessage()
|
||||
{
|
||||
var ex = new IdentityAssertionGrantException("Base message", "invalid_grant", "Token expired");
|
||||
|
||||
Assert.Contains("Base message", ex.Message);
|
||||
Assert.Contains("invalid_grant", ex.Message);
|
||||
Assert.Contains("Token expired", ex.Message);
|
||||
Assert.Equal("invalid_grant", ex.ErrorCode);
|
||||
Assert.Equal("Token expired", ex.ErrorDescription);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IdentityAssertionGrantException_WithErrorUri_StoresIt()
|
||||
{
|
||||
var ex = new IdentityAssertionGrantException("msg", "error", "desc", "https://docs.example.com/error");
|
||||
|
||||
Assert.Equal("https://docs.example.com/error", ex.ErrorUri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IdentityAssertionGrantException_WithoutErrorDetails_PlainMessage()
|
||||
{
|
||||
var ex = new IdentityAssertionGrantException("Simple error");
|
||||
|
||||
Assert.Equal("Simple error", ex.Message);
|
||||
Assert.Null(ex.ErrorCode);
|
||||
Assert.Null(ex.ErrorDescription);
|
||||
Assert.Null(ex.ErrorUri);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static HttpResponseMessage JsonResponse(HttpStatusCode statusCode, JsonObject payload)
|
||||
{
|
||||
return new HttpResponseMessage(statusCode)
|
||||
{
|
||||
Content = new StringContent(payload.ToJsonString(), System.Text.Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class MockHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
public Func<HttpRequestMessage, HttpResponseMessage>? Handler { get; set; }
|
||||
public Func<HttpRequestMessage, Task<HttpResponseMessage>>? AsyncHandler { get; set; }
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (AsyncHandler is not null)
|
||||
{
|
||||
return await AsyncHandler(request);
|
||||
}
|
||||
|
||||
if (Handler is not null)
|
||||
{
|
||||
return Handler(request);
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.InternalServerError)
|
||||
{
|
||||
Content = new StringContent("No mock response configured")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Loading…
Reference in a new issue