diff --git a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProvider.cs b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProvider.cs index 41d2ea36d..ad5524812 100644 --- a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProvider.cs @@ -169,6 +169,16 @@ private async Task AcquireAccessTokenAsync( var mcpAuthMetadata = await IdentityAssertionGrant.DiscoverAuthServerMetadataAsync( authorizationServerUrl, _httpClient, cancellationToken).ConfigureAwait(false); + // The issuer becomes the JAG audience below, so validate it first (RFC 8414 Section 3.3): it MUST be + // identical to the issuer identifier used for discovery. Like ClientOAuthProvider, compare the exact + // strings without normalizing case, trailing slashes or percent-encoding. + if (mcpAuthMetadata.Issuer is not null && + !string.Equals(mcpAuthMetadata.Issuer.OriginalString, authorizationServerUrl.OriginalString, StringComparison.Ordinal)) + { + throw new IdentityAssertionGrantException( + $"Authorization server metadata issuer '{mcpAuthMetadata.Issuer.OriginalString}' does not match the expected issuer '{authorizationServerUrl.OriginalString}' (RFC 8414 Section 3.3)."); + } + var mcpTokenEndpoint = mcpAuthMetadata.TokenEndpoint?.ToString() ?? throw new IdentityAssertionGrantException( $"MCP authorization server metadata at {authorizationServerUrl} missing token_endpoint."); @@ -196,7 +206,10 @@ private async Task AcquireAccessTokenAsync( new RequestJwtAuthGrantOptions { TokenEndpoint = idpTokenEndpoint, - Audience = authorizationServerUrl.ToString(), + // The JAG audience is the MCP authorization server's issuer identifier (RFC 8414), not the + // caller-supplied URL, which can differ from it (e.g. Uri.ToString() appends a trailing slash). + // OriginalString preserves the advertised issuer exactly as published. + Audience = mcpAuthMetadata.Issuer?.OriginalString ?? authorizationServerUrl.ToString(), Resource = resourceUrl.ToString(), IdToken = idToken, ClientId = _options.IdpClientId, diff --git a/tests/ModelContextProtocol.Tests/IdentityAssertionGrantTests.cs b/tests/ModelContextProtocol.Tests/IdentityAssertionGrantTests.cs index 92e81c93c..09e6109b4 100644 --- a/tests/ModelContextProtocol.Tests/IdentityAssertionGrantTests.cs +++ b/tests/ModelContextProtocol.Tests/IdentityAssertionGrantTests.cs @@ -95,6 +95,108 @@ public async Task IdentityAssertionGrantProvider_FullFlow_ReturnsAccessToken() Assert.Equal(3600, tokens.ExpiresIn); } + [Theory] + [InlineData("https://auth.example.com", "https://auth.example.com", "https://auth.example.com")] + [InlineData("https://auth.example.com/tenant", "https://auth.example.com/tenant", "https://auth.example.com/tenant")] + [InlineData("https://auth.example.com", null, "https://auth.example.com/")] + public async Task IdentityAssertionGrantProvider_UsesDiscoveredIssuerAsJagAudience( + string authorizationServerUrl, string? advertisedIssuer, string expectedAudience) + { + string? audience = null; + _mockHandler.AsyncHandler = async request => + { + var url = request.RequestUri!.ToString(); + if (url.Contains(".well-known")) + { + return JsonResponse(HttpStatusCode.OK, new JsonObject + { + ["issuer"] = advertisedIssuer, + ["token_endpoint"] = "https://auth.example.com/token", + }); + } + + if (url.Contains("idp.example.com")) + { + var body = await request.Content!.ReadAsStringAsync(TestContext.Current.CancellationToken); + audience = body.Split('&') + .Select(pair => pair.Split('=')) + .Where(kv => kv[0] == "audience") + .Select(kv => WebUtility.UrlDecode(kv[1])) + .Single(); + + 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"] = "final-access-token", + ["token_type"] = "Bearer", + }); + }; + + var provider = new IdentityAssertionGrantProvider( + new IdentityAssertionGrantProviderOptions + { + ClientId = "mcp-client-id", + IdpTokenEndpoint = "https://idp.example.com/token", + IdpClientId = "idp-client-id", + IdTokenCallback = (_, _) => Task.FromResult("mock-id-token"), + }, + _httpClient); + + await provider.GetAccessTokenAsync( + new Uri("https://resource.example.com"), + new Uri(authorizationServerUrl), + TestContext.Current.CancellationToken); + + Assert.Equal(expectedAudience, audience); + } + + [Theory] + [InlineData("https://auth.example.com/", "https://auth.example.com/tenant")] + [InlineData("https://auth.example.com/", "https://auth.example.com")] + public async Task IdentityAssertionGrantProvider_RejectsMismatchedIssuer( + string authorizationServerUrl, string advertisedIssuer) + { + var idpCalled = false; + _mockHandler.Handler = request => + { + if (request.RequestUri!.ToString().Contains("idp.example.com")) + { + idpCalled = true; + } + + return JsonResponse(HttpStatusCode.OK, new JsonObject + { + ["issuer"] = advertisedIssuer, + ["token_endpoint"] = "https://auth.example.com/token", + }); + }; + + var provider = new IdentityAssertionGrantProvider( + new IdentityAssertionGrantProviderOptions + { + ClientId = "mcp-client-id", + IdpTokenEndpoint = "https://idp.example.com/token", + IdpClientId = "idp-client-id", + IdTokenCallback = (_, _) => Task.FromResult("mock-id-token"), + }, + _httpClient); + + var ex = await Assert.ThrowsAsync(() => provider.GetAccessTokenAsync( + new Uri("https://resource.example.com"), + new Uri(authorizationServerUrl), + TestContext.Current.CancellationToken)); + + Assert.Contains("RFC 8414 Section 3.3", ex.Message); + Assert.False(idpCalled); + } + [Fact] public Task IdentityAssertionGrantProvider_DefaultsToPostRegardlessOfMetadataOrder() => AssertMcpTokenEndpointAuthenticationAsync(