Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ internal static class IdentityAssertionGrant
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";
public const string TokenTypeIdToken = IdentityAssertionGrantSubjectTokenTypes.IdToken;

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

/// <summary>
/// Token type URN for Identity Assertion JWT Authorization Grants.
Expand Down Expand Up @@ -56,15 +56,16 @@ public static async Task<string> RequestJwtAuthorizationGrantAsync(
Throw.IfNullOrWhiteSpace(options.TokenEndpoint);
Throw.IfNullOrWhiteSpace(options.Audience);
Throw.IfNullOrWhiteSpace(options.Resource);
Throw.IfNullOrWhiteSpace(options.IdToken);
Throw.IfNullOrWhiteSpace(options.SubjectToken);
Throw.IfNullOrWhiteSpace(options.SubjectTokenType);
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,
["subject_token"] = options.SubjectToken,
["subject_token_type"] = options.SubjectTokenType,
["audience"] = options.Audience,
["resource"] = options.Resource,
["client_id"] = options.ClientId,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
namespace ModelContextProtocol.Authentication;

/// <summary>
/// Represents a method that returns an OIDC ID token for use in a Cross-Application Access authorization flow.
/// Represents a method that returns a subject 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.
/// A task that represents the asynchronous operation. The task result contains the subject token obtained
/// from the enterprise Identity Provider (e.g., via SSO login). This is an OIDC ID token by default, or a
/// SAML 2.0 assertion when configured through <see cref="IdentityAssertionGrantProviderOptions.SubjectTokenType"/>.
/// The provider uses the token to perform the RFC 8693 token exchange and obtain a JWT Authorization Grant.
/// </returns>
public delegate Task<string> IdentityAssertionGrantIdTokenCallback(
IdentityAssertionGrantContext context,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ namespace ModelContextProtocol.Authentication;
/// </para>
/// <list type="number">
/// <item><description>
/// The <see cref="IdentityAssertionGrantProviderOptions.IdTokenCallback"/> is called to obtain an OIDC ID token.
/// The <see cref="IdentityAssertionGrantProviderOptions.IdTokenCallback"/> is called to obtain a subject 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).
/// exchanging the subject 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
Expand Down Expand Up @@ -91,6 +91,7 @@ public IdentityAssertionGrantProvider(

Throw.IfNullOrWhiteSpace(options.ClientId);
Throw.IfNullOrWhiteSpace(options.IdpClientId);
Throw.IfNullOrWhiteSpace(options.SubjectTokenType);

if (string.IsNullOrEmpty(options.IdpUrl) && string.IsNullOrEmpty(options.IdpTokenEndpoint))
{
Expand Down Expand Up @@ -173,22 +174,22 @@ private async Task<TokenContainer> AcquireAccessTokenAsync(
?? 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
// Step 2: Call the callback to get the caller's subject 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);
_logger.LogDebug("Requesting subject token via callback");
var subjectToken = await _options.IdTokenCallback(context, cancellationToken).ConfigureAwait(false);

if (string.IsNullOrEmpty(idToken))
if (string.IsNullOrEmpty(subjectToken))
{
throw new IdentityAssertionGrantException("ID token callback returned a null or empty token.");
throw new IdentityAssertionGrantException("Subject token callback returned a null or empty token.");
}

// Step 3: RFC 8693 token exchange — ID token → JWT Authorization Grant (JAG) at the enterprise IdP
// Step 3: RFC 8693 token exchange — subject 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);

Expand All @@ -198,7 +199,8 @@ private async Task<TokenContainer> AcquireAccessTokenAsync(
TokenEndpoint = idpTokenEndpoint,
Audience = authorizationServerUrl.ToString(),
Resource = resourceUrl.ToString(),
IdToken = idToken,
SubjectToken = subjectToken,
SubjectTokenType = _options.SubjectTokenType,
ClientId = _options.IdpClientId,
ClientSecret = _options.IdpClientSecret,
Scope = _options.IdpScope,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,16 +61,29 @@ public sealed class IdentityAssertionGrantProviderOptions
public string? IdpScope { get; set; }

/// <summary>
/// Gets or sets the callback that supplies the OIDC ID token for the Cross-Application Access flow.
/// Gets or sets the RFC 8693 token type identifier for the subject token returned by
/// <see cref="IdTokenCallback"/>.
/// </summary>
/// <remarks>
/// The default is <see cref="IdentityAssertionGrantSubjectTokenTypes.IdToken"/>. Set this to
/// <see cref="IdentityAssertionGrantSubjectTokenTypes.Saml2"/> when the callback returns a SAML 2.0 assertion.
/// Custom RFC 8693 subject token type identifiers are also supported.
/// </remarks>
public string SubjectTokenType { get; set; } = IdentityAssertionGrantSubjectTokenTypes.IdToken;

/// <summary>
/// Gets or sets the callback that supplies the subject 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).
/// subject token obtained from the enterprise Identity Provider (e.g., from an SSO login session).
/// By default this is an OIDC ID token. When <see cref="SubjectTokenType"/> is
/// <see cref="IdentityAssertionGrantSubjectTokenTypes.Saml2"/>, return a SAML 2.0 assertion instead.
/// </para>
/// <para>
/// The provider will use the returned ID token to internally perform the RFC 8693 token exchange at the
/// The provider will use the returned subject 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>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace ModelContextProtocol.Authentication;

/// <summary>
/// Provides subject token type identifiers supported by the Identity Assertion Authorization Grant flow.
/// </summary>
public static class IdentityAssertionGrantSubjectTokenTypes
{
/// <summary>
/// The RFC 8693 token type identifier for an OpenID Connect ID token.
/// </summary>
public const string IdToken = "urn:ietf:params:oauth:token-type:id_token";

/// <summary>
/// The RFC 8693 token type identifier for a SAML 2.0 assertion.
/// </summary>
public const string Saml2 = "urn:ietf:params:oauth:token-type:saml2";
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,14 @@ internal sealed class RequestJwtAuthGrantOptions
public required string Resource { get; set; }

/// <summary>
/// Gets or sets the OIDC ID token to exchange.
/// Gets or sets the subject token to exchange.
/// </summary>
public required string IdToken { get; set; }
public required string SubjectToken { get; set; }

/// <summary>
/// Gets or sets the RFC 8693 subject token type identifier.
/// </summary>
public required string SubjectTokenType { get; set; }

/// <summary>
/// Gets or sets the client ID for authentication with the IDP.
Expand Down
91 changes: 91 additions & 0 deletions tests/ModelContextProtocol.Tests/IdentityAssertionGrantTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,79 @@ public async Task IdentityAssertionGrantProvider_FullFlow_ReturnsAccessToken()
Assert.Equal(3600, tokens.ExpiresIn);
}

[Theory]
[InlineData(null, IdentityAssertionGrantSubjectTokenTypes.IdToken)]
[InlineData(IdentityAssertionGrantSubjectTokenTypes.Saml2, IdentityAssertionGrantSubjectTokenTypes.Saml2)]
public async Task IdentityAssertionGrantProvider_SendsConfiguredSubjectTokenType(
string? configuredSubjectTokenType,
string expectedSubjectTokenType)
{
string? tokenExchangeBody = null;
_mockHandler.AsyncHandler = async 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"))
{
tokenExchangeBody = await request.Content!.ReadAsStringAsync(TestContext.Current.CancellationToken);
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",
});
}

return new HttpResponseMessage(HttpStatusCode.NotFound);
};

var options = new IdentityAssertionGrantProviderOptions
{
ClientId = "mcp-client-id",
IdpTokenEndpoint = "https://idp.example.com/token",
IdpClientId = "idp-client-id",
IdTokenCallback = (_, _) => Task.FromResult("mock-subject-token"),
};

if (configuredSubjectTokenType is not null)
{
options.SubjectTokenType = configuredSubjectTokenType;
}

var provider = new IdentityAssertionGrantProvider(options, _httpClient);

await provider.GetAccessTokenAsync(
resourceUrl: new Uri("https://mcp-server.example.com"),
authorizationServerUrl: new Uri("https://auth.mcp-server.example.com"),
TestContext.Current.CancellationToken);

Assert.NotNull(tokenExchangeBody);
Assert.Contains("subject_token=mock-subject-token", tokenExchangeBody, StringComparison.Ordinal);
Assert.Contains(
$"subject_token_type={Uri.EscapeDataString(expectedSubjectTokenType)}",
tokenExchangeBody,
StringComparison.Ordinal);
}

[Fact]
public Task IdentityAssertionGrantProvider_DefaultsToPostRegardlessOfMetadataOrder() =>
AssertMcpTokenEndpointAuthenticationAsync(
Expand Down Expand Up @@ -330,6 +403,24 @@ public void IdentityAssertionGrantProvider_MissingIdTokenCallback_ThrowsArgument
_httpClient));
}

[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void IdentityAssertionGrantProvider_MissingSubjectTokenType_ThrowsArgumentException(string? subjectTokenType)
{
Assert.Throws<ArgumentException>(() => new IdentityAssertionGrantProvider(
new IdentityAssertionGrantProviderOptions
{
ClientId = "client-id",
IdpTokenEndpoint = "https://idp.example.com/token",
IdpClientId = "idp-client-id",
IdTokenCallback = (_, _) => Task.FromResult("test"),
SubjectTokenType = subjectTokenType!,
},
_httpClient));
}

[Fact]
public void IdentityAssertionGrantProvider_MissingIdpConfig_ThrowsArgumentException()
{
Expand Down