From d580aef094dab990683e84c236e7963b3c0516db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mauricio=20Acu=C3=B1a?= Date: Wed, 23 Sep 2026 23:02:40 -0300 Subject: [PATCH] fix(auth): use the discovered issuer as the ID-JAG token-exchange audience IdentityAssertionGrantProvider passed authorizationServerUrl.ToString() as the RFC 8693 audience. Uri.ToString() appends a trailing slash to an empty path, so the common case (https://auth.example.com, whose metadata advertises the issuer without a slash) sent an audience that did not match the issuer. Use the issuer from the authorization server metadata already fetched in step 1, falling back to the configured URL when the metadata omits it. Fixes #1617 Co-Authored-By: Claude Opus 5.5 --- .../IdentityAssertionGrantProvider.cs | 5 +- .../IdentityAssertionGrantTests.cs | 62 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProvider.cs b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProvider.cs index 41d2ea36d..1458069ce 100644 --- a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProvider.cs @@ -196,7 +196,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..4915a990f 100644 --- a/tests/ModelContextProtocol.Tests/IdentityAssertionGrantTests.cs +++ b/tests/ModelContextProtocol.Tests/IdentityAssertionGrantTests.cs @@ -95,6 +95,68 @@ 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/", "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); + } + [Fact] public Task IdentityAssertionGrantProvider_DefaultsToPostRegardlessOfMetadataOrder() => AssertMcpTokenEndpointAuthenticationAsync(