Skip to content
Open
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 @@ -169,6 +169,16 @@ private async Task<TokenContainer> 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.");
Expand Down Expand Up @@ -196,7 +206,10 @@ private async Task<TokenContainer> 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,
Expand Down
102 changes: 102 additions & 0 deletions tests/ModelContextProtocol.Tests/IdentityAssertionGrantTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IdentityAssertionGrantException>(() => 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(
Expand Down