Skip to content

Commit b653294

Browse files
Varun SharmaCopilot
andcommitted
Fix silent token refresh to respect IncludeResourceIndicator and add doc warning
- GetAccessTokenSilentAsync now omits the resource parameter from token refresh requests when IncludeResourceIndicator is false, matching the behavior of the 401 handler and authorization URL paths. - Added warning comment in getting-started.md about .GetAwaiter().GetResult() deadlock risk in SynchronizationContext environments. - Added end-to-end tests with mock Entra ID server (ExpectResource=false) validating full auth flow and token refresh without resource parameter. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent ac75230 commit b653294

File tree

3 files changed

+111
-1
lines changed

3 files changed

+111
-1
lines changed

docs/concepts/getting-started.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,9 @@ builder.Services.AddSingleton(sp =>
154154
Command = "dotnet",
155155
Arguments = ["run", "--project", "path/to/server"],
156156
});
157+
// Note: .GetAwaiter().GetResult() is used here for simplicity in console apps.
158+
// In ASP.NET Core or other environments with a SynchronizationContext,
159+
// use an IHostedService to initialize async resources instead.
157160
return McpClient.CreateAsync(transport, loggerFactory: loggerFactory)
158161
.GetAwaiter().GetResult();
159162
});

src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ internal override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage r
156156
// Try to refresh the access token if it is invalid and we have a refresh token.
157157
if (_authServerMetadata is not null && tokens?.RefreshToken is { Length: > 0 } refreshToken)
158158
{
159-
var accessToken = await RefreshTokensAsync(refreshToken, resourceUri.ToString(), _authServerMetadata, cancellationToken).ConfigureAwait(false);
159+
var accessToken = await RefreshTokensAsync(refreshToken, _includeResourceIndicator ? resourceUri.ToString() : null, _authServerMetadata, cancellationToken).ConfigureAwait(false);
160160
return (accessToken, true);
161161
}
162162

tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1297,4 +1297,111 @@ await Assert.ThrowsAsync<McpException>(() => McpClient.CreateAsync(
12971297
Assert.False(query.ContainsKey("resource"), "The 'resource' query parameter should not be present when IncludeResourceIndicator is false.");
12981298
Assert.True(query.ContainsKey("scope"), "The 'scope' query parameter should still be present.");
12991299
}
1300+
1301+
[Fact]
1302+
public async Task CanAuthenticate_WithoutResourceIndicator_EndToEnd()
1303+
{
1304+
// Simulate an Entra ID-like server that rejects the 'resource' parameter.
1305+
TestOAuthServer.ExpectResource = false;
1306+
1307+
// Without resource indicator the token audience falls back to the client ID,
1308+
// matching real Entra ID behavior. Configure the server to accept it.
1309+
Builder.Services.Configure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
1310+
{
1311+
options.TokenValidationParameters.ValidAudiences = [McpServerUrl, "demo-client"];
1312+
});
1313+
1314+
await using var app = await StartMcpServerAsync();
1315+
1316+
await using var transport = new HttpClientTransport(new()
1317+
{
1318+
Endpoint = new(McpServerUrl),
1319+
OAuth = new()
1320+
{
1321+
ClientId = "demo-client",
1322+
ClientSecret = "demo-secret",
1323+
RedirectUri = new Uri("http://localhost:1179/callback"),
1324+
IncludeResourceIndicator = false,
1325+
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
1326+
},
1327+
}, HttpClient, LoggerFactory);
1328+
1329+
// This would fail with "invalid_target" if the resource parameter leaked through
1330+
// in either the authorization, token exchange, or silent refresh paths.
1331+
await using var client = await McpClient.CreateAsync(
1332+
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
1333+
}
1334+
1335+
[Fact]
1336+
public async Task CanAuthenticate_WithoutResourceIndicator_TokenRefresh()
1337+
{
1338+
// Simulate an Entra ID-like server that rejects the 'resource' parameter.
1339+
TestOAuthServer.ExpectResource = false;
1340+
1341+
var hasForcedRefresh = false;
1342+
1343+
Builder.Services.AddMcpServer(options =>
1344+
{
1345+
options.ToolCollection = new();
1346+
});
1347+
1348+
// Without resource indicator the token audience falls back to the client ID.
1349+
Builder.Services.Configure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
1350+
{
1351+
options.TokenValidationParameters.ValidAudiences = [McpServerUrl, "demo-client"];
1352+
});
1353+
1354+
await using var app = await StartMcpServerAsync(configureMiddleware: app =>
1355+
{
1356+
app.Use(async (context, next) =>
1357+
{
1358+
if (context.Request.Method == HttpMethods.Post && context.Request.Path == "/" && !hasForcedRefresh)
1359+
{
1360+
context.Request.EnableBuffering();
1361+
1362+
var message = await JsonSerializer.DeserializeAsync(
1363+
context.Request.Body,
1364+
McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage)),
1365+
context.RequestAborted) as JsonRpcMessage;
1366+
1367+
context.Request.Body.Position = 0;
1368+
1369+
if (message is JsonRpcRequest request && request.Method == "tools/list")
1370+
{
1371+
hasForcedRefresh = true;
1372+
1373+
// Return 401 to force token refresh
1374+
await context.ChallengeAsync(JwtBearerDefaults.AuthenticationScheme);
1375+
await context.Response.StartAsync(context.RequestAborted);
1376+
await context.Response.Body.FlushAsync(context.RequestAborted);
1377+
return;
1378+
}
1379+
}
1380+
1381+
await next(context);
1382+
});
1383+
});
1384+
1385+
await using var transport = new HttpClientTransport(new()
1386+
{
1387+
Endpoint = new(McpServerUrl),
1388+
OAuth = new()
1389+
{
1390+
ClientId = "demo-client",
1391+
ClientSecret = "demo-secret",
1392+
RedirectUri = new Uri("http://localhost:1179/callback"),
1393+
IncludeResourceIndicator = false,
1394+
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
1395+
},
1396+
}, HttpClient, LoggerFactory);
1397+
1398+
await using var client = await McpClient.CreateAsync(
1399+
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
1400+
1401+
// This triggers the 401 → token refresh path. If the resource parameter
1402+
// leaks into the refresh request, the mock Entra ID server returns invalid_target.
1403+
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
1404+
1405+
Assert.True(TestOAuthServer.HasRefreshedToken, "Token refresh should have occurred.");
1406+
}
13001407
}

0 commit comments

Comments
 (0)