From eb39d6a665ac9ac4355068c597f87910429d0119 Mon Sep 17 00:00:00 2001 From: Zhenyu Wang <77710007+zhenyu-02@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:13:45 +0800 Subject: [PATCH] feat(auth): support SAML subject tokens in identity assertion grants --- .../Authentication/IdentityAssertionGrant.cs | 11 ++- .../IdentityAssertionGrantIdTokenCallback.cs | 9 +- .../IdentityAssertionGrantProvider.cs | 20 ++-- .../IdentityAssertionGrantProviderOptions.cs | 19 +++- ...IdentityAssertionGrantSubjectTokenTypes.cs | 17 ++++ .../RequestJwtAuthGrantOptions.cs | 9 +- .../IdentityAssertionGrantTests.cs | 91 +++++++++++++++++++ 7 files changed, 153 insertions(+), 23 deletions(-) create mode 100644 src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantSubjectTokenTypes.cs diff --git a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrant.cs b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrant.cs index ecc5eb35d..692b731d4 100644 --- a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrant.cs +++ b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrant.cs @@ -21,10 +21,10 @@ internal static class IdentityAssertionGrant public const string GrantTypeJwtBearer = "urn:ietf:params:oauth:grant-type:jwt-bearer"; /// Token type URN for OpenID Connect ID Tokens (RFC 8693). - public const string TokenTypeIdToken = "urn:ietf:params:oauth:token-type:id_token"; + public const string TokenTypeIdToken = IdentityAssertionGrantSubjectTokenTypes.IdToken; /// Token type URN for SAML 2.0 assertions (RFC 8693). - public const string TokenTypeSaml2 = "urn:ietf:params:oauth:token-type:saml2"; + public const string TokenTypeSaml2 = IdentityAssertionGrantSubjectTokenTypes.Saml2; /// /// Token type URN for Identity Assertion JWT Authorization Grants. @@ -56,15 +56,16 @@ public static async Task 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 { ["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, diff --git a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantIdTokenCallback.cs b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantIdTokenCallback.cs index 2951d1e8b..73c884a81 100644 --- a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantIdTokenCallback.cs +++ b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantIdTokenCallback.cs @@ -1,16 +1,17 @@ namespace ModelContextProtocol.Authentication; /// -/// 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. /// /// /// Context containing the MCP resource and authorization server URLs discovered during the OAuth flow. /// /// The to monitor for cancellation requests. /// -/// 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 . +/// The provider uses the token to perform the RFC 8693 token exchange and obtain a JWT Authorization Grant. /// public delegate Task IdentityAssertionGrantIdTokenCallback( IdentityAssertionGrantContext context, diff --git a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProvider.cs b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProvider.cs index 41d2ea36d..d98c94ad5 100644 --- a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProvider.cs @@ -14,14 +14,14 @@ namespace ModelContextProtocol.Authentication; /// /// /// -/// The is called to obtain an OIDC ID token. +/// The is called to obtain a subject token. /// It receives a with the discovered resource and authorization /// server URLs. /// /// /// The provider performs the RFC 8693 token exchange at the enterprise Identity Provider /// (using the configured IdpTokenEndpoint or discovered from IdpUrl), -/// exchanging the ID token for a JWT Authorization Grant (JAG). +/// exchanging the subject token for a JWT Authorization Grant (JAG). /// /// /// The JAG is then exchanged for an access token at the MCP Server's authorization server @@ -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)) { @@ -173,22 +174,22 @@ private async Task 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); @@ -198,7 +199,8 @@ private async Task 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, diff --git a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProviderOptions.cs b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProviderOptions.cs index ecbd0551e..8064131cc 100644 --- a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProviderOptions.cs +++ b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProviderOptions.cs @@ -61,16 +61,29 @@ public sealed class IdentityAssertionGrantProviderOptions public string? IdpScope { get; set; } /// - /// 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 + /// . + /// + /// + /// The default is . Set this to + /// when the callback returns a SAML 2.0 assertion. + /// Custom RFC 8693 subject token type identifiers are also supported. + /// + public string SubjectTokenType { get; set; } = IdentityAssertionGrantSubjectTokenTypes.IdToken; + + /// + /// Gets or sets the callback that supplies the subject token for the Cross-Application Access flow. /// /// /// /// This callback is invoked after the MCP resource and authorization server URLs have been discovered. /// It receives a 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 is + /// , return a SAML 2.0 assertion instead. /// /// - /// 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. /// diff --git a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantSubjectTokenTypes.cs b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantSubjectTokenTypes.cs new file mode 100644 index 000000000..ed8ba2c55 --- /dev/null +++ b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantSubjectTokenTypes.cs @@ -0,0 +1,17 @@ +namespace ModelContextProtocol.Authentication; + +/// +/// Provides subject token type identifiers supported by the Identity Assertion Authorization Grant flow. +/// +public static class IdentityAssertionGrantSubjectTokenTypes +{ + /// + /// The RFC 8693 token type identifier for an OpenID Connect ID token. + /// + public const string IdToken = "urn:ietf:params:oauth:token-type:id_token"; + + /// + /// The RFC 8693 token type identifier for a SAML 2.0 assertion. + /// + public const string Saml2 = "urn:ietf:params:oauth:token-type:saml2"; +} diff --git a/src/ModelContextProtocol.Core/Authentication/RequestJwtAuthGrantOptions.cs b/src/ModelContextProtocol.Core/Authentication/RequestJwtAuthGrantOptions.cs index 7e83b198d..adbf82f5d 100644 --- a/src/ModelContextProtocol.Core/Authentication/RequestJwtAuthGrantOptions.cs +++ b/src/ModelContextProtocol.Core/Authentication/RequestJwtAuthGrantOptions.cs @@ -21,9 +21,14 @@ internal sealed class RequestJwtAuthGrantOptions public required string Resource { get; set; } /// - /// Gets or sets the OIDC ID token to exchange. + /// Gets or sets the subject token to exchange. /// - public required string IdToken { get; set; } + public required string SubjectToken { get; set; } + + /// + /// Gets or sets the RFC 8693 subject token type identifier. + /// + public required string SubjectTokenType { get; set; } /// /// Gets or sets the client ID for authentication with the IDP. diff --git a/tests/ModelContextProtocol.Tests/IdentityAssertionGrantTests.cs b/tests/ModelContextProtocol.Tests/IdentityAssertionGrantTests.cs index 92e81c93c..5632e5bae 100644 --- a/tests/ModelContextProtocol.Tests/IdentityAssertionGrantTests.cs +++ b/tests/ModelContextProtocol.Tests/IdentityAssertionGrantTests.cs @@ -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( @@ -330,6 +403,24 @@ public void IdentityAssertionGrantProvider_MissingIdTokenCallback_ThrowsArgument _httpClient)); } + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void IdentityAssertionGrantProvider_MissingSubjectTokenType_ThrowsArgumentException(string? subjectTokenType) + { + Assert.Throws(() => 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() {