csharp-sdk/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/TokenCacheTests.cs
Stephen Halter e8b80f0e6d
Enable additional client conformance scenarios (#1763)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-07-28 13:39:26 -07:00

652 lines
26 KiB
C#

using ModelContextProtocol.Authentication;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
namespace ModelContextProtocol.AspNetCore.Tests.OAuth;
public class TokenCacheTests : OAuthTestBase
{
public TokenCacheTests(ITestOutputHelper outputHelper)
: base(outputHelper)
{
}
[Fact]
public async Task GetTokenAsync_CachedAccessTokenIsUsedForOutgoingRequests()
{
await using var app = await StartMcpServerAsync();
var tokenCache = new TestTokenCache();
bool authDelegateCalledInitially = false;
await using var setupTransport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new()
{
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationCallbackHandler = (context, ct) =>
{
authDelegateCalledInitially = true;
return HandleAuthorizationUrlAsync(context, ct);
},
TokenCache = tokenCache,
},
}, HttpClient, LoggerFactory);
await using (var setupClient = await McpClient.CreateAsync(setupTransport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken))
{
// Just connecting should trigger auth and storage.
}
Assert.True(authDelegateCalledInitially, "AuthorizationCallbackHandler should be called to get initial token");
Assert.NotNull(tokenCache.LastStoredToken);
var authDelegateCalledAgain = false;
await using var transport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new()
{
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationCallbackHandler = (context, ct) =>
{
authDelegateCalledAgain = true;
return HandleAuthorizationUrlAsync(context, ct);
},
TokenCache = tokenCache
},
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
Assert.False(authDelegateCalledAgain, "AuthorizationCallbackHandler should not be called when token is valid");
}
[Fact]
public async Task StoreTokenAsync_NewlyAcquiredAccessTokenIsCached()
{
await using var app = await StartMcpServerAsync();
var tokenCache = new TestTokenCache();
await using var transport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new()
{
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
TokenCache = tokenCache
},
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
Assert.NotNull(tokenCache.LastStoredToken);
Assert.False(string.IsNullOrEmpty(tokenCache.LastStoredToken.AccessToken));
}
[Fact]
public async Task GetTokenAsync_InvalidCachedTokenTriggersAuthDelegate()
{
await using var app = await StartMcpServerAsync();
var tokenCache = new TestTokenCache(CreateInvalidToken());
bool authDelegateCalled = false;
await using var transport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new()
{
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationCallbackHandler = (context, ct) =>
{
authDelegateCalled = true;
return HandleAuthorizationUrlAsync(context, ct);
},
TokenCache = tokenCache,
},
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
Assert.True(authDelegateCalled, "AuthorizationCallbackHandler should be called when cached token is invalid");
Assert.NotNull(tokenCache.LastStoredToken);
Assert.NotEqual("invalid-token", tokenCache.LastStoredToken.AccessToken);
}
[Fact]
public async Task GetTokenAsync_InvalidAccessTokenTriggersRefresh()
{
await using var app = await StartMcpServerAsync();
var tokenCache = new TestTokenCache();
bool authDelegateCalledInitially = false;
await using var setupTransport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new()
{
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationCallbackHandler = (context, ct) =>
{
authDelegateCalledInitially = true;
return HandleAuthorizationUrlAsync(context, ct);
},
TokenCache = tokenCache,
},
}, HttpClient, LoggerFactory);
await using (var setupClient = await McpClient.CreateAsync(setupTransport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken))
{
// Just connecting should trigger auth and storage.
}
Assert.True(authDelegateCalledInitially, "AuthorizationCallbackHandler should be called to get initial token");
Assert.False(TestOAuthServer.HasRefreshedToken, "Token should not have been refreshed yet");
Assert.NotNull(tokenCache.LastStoredToken);
// Invalidate the access token but keep the refresh token valid (if any)
tokenCache.LastStoredToken.AccessToken = "invalid-token";
var authDelegateCalledAgain = false;
await using var transport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new()
{
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationCallbackHandler = (context, ct) =>
{
authDelegateCalledAgain = true;
return HandleAuthorizationUrlAsync(context, ct);
},
TokenCache = tokenCache
},
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
Assert.False(authDelegateCalledAgain, "AuthorizationCallbackHandler should not be called when refresh token is valid");
Assert.True(TestOAuthServer.HasRefreshedToken, "Token should have been refreshed");
Assert.NotEqual("invalid-token", tokenCache.LastStoredToken.AccessToken);
}
[Fact]
public async Task GetTokenAsync_ExplicitClientDoesNotRefreshTokenFromDifferentAuthorizationServer()
{
await using var app = await StartMcpServerAsync();
var tokenCache = new TestTokenCache();
await using var setupTransport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new()
{
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
TokenCache = tokenCache,
},
}, HttpClient, LoggerFactory);
await using (var setupClient = await McpClient.CreateAsync(
setupTransport,
loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken))
{
}
Assert.NotNull(tokenCache.LastStoredToken);
tokenCache.LastStoredToken.AccessToken = "invalid-token";
tokenCache.LastStoredToken.AuthorizationServer = "https://different-authorization-server.example.com";
var authorizationCallbackCalled = false;
await using var transport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new()
{
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationCallbackHandler = (context, ct) =>
{
authorizationCallbackCalled = true;
return HandleAuthorizationUrlAsync(context, ct);
},
TokenCache = tokenCache,
},
}, HttpClient, LoggerFactory);
var exception = await Assert.ThrowsAsync<McpException>(() => McpClient.CreateAsync(
transport,
loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken));
Assert.Contains("explicitly configured client credentials", exception.Message);
Assert.False(authorizationCallbackCalled);
Assert.False(TestOAuthServer.HasRefreshedToken);
Assert.Equal("https://different-authorization-server.example.com", tokenCache.LastStoredToken.AuthorizationServer);
}
[Fact]
public async Task GetTokenAsync_ExplicitClientDoesNotRefreshTokenIssuedToDifferentClient()
{
await using var app = await StartMcpServerAsync();
var tokenCache = new TestTokenCache();
await using var setupTransport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new()
{
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
TokenCache = tokenCache,
},
}, HttpClient, LoggerFactory);
await using (var setupClient = await McpClient.CreateAsync(
setupTransport,
loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken))
{
}
Assert.NotNull(tokenCache.LastStoredToken);
tokenCache.LastStoredToken.AccessToken = "invalid-token";
tokenCache.LastStoredToken.ClientId = "different-client";
var authorizationCallbackCalled = false;
await using var transport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new()
{
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationCallbackHandler = (context, ct) =>
{
authorizationCallbackCalled = true;
return HandleAuthorizationUrlAsync(context, ct);
},
TokenCache = tokenCache,
},
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(
transport,
loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken);
Assert.True(authorizationCallbackCalled);
Assert.False(TestOAuthServer.HasRefreshedToken);
Assert.Equal("demo-client", tokenCache.LastStoredToken.ClientId);
Assert.Equal(OAuthServerUrl, tokenCache.LastStoredToken.AuthorizationServer);
}
[Fact]
public async Task GetTokenAsync_ExplicitClientWithLegacyUnboundCache_Reauthorizes()
{
await using var app = await StartMcpServerAsync();
var tokenCache = new TestTokenCache();
await using var setupTransport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new()
{
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
TokenCache = tokenCache,
},
}, HttpClient, LoggerFactory);
await using (var setupClient = await McpClient.CreateAsync(
setupTransport,
loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken))
{
}
Assert.NotNull(tokenCache.LastStoredToken);
tokenCache.LastStoredToken.AccessToken = "invalid-token";
tokenCache.LastStoredToken.AuthorizationServer = null;
var authorizationCallbackCalled = false;
await using var transport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new()
{
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationCallbackHandler = (context, ct) =>
{
authorizationCallbackCalled = true;
return HandleAuthorizationUrlAsync(context, ct);
},
TokenCache = tokenCache,
},
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(
transport,
loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken);
Assert.True(authorizationCallbackCalled);
Assert.False(TestOAuthServer.HasRefreshedToken);
Assert.Equal(OAuthServerUrl, tokenCache.LastStoredToken.AuthorizationServer);
}
[Fact]
public async Task GetTokenAsync_ColdStartWithDynamicRegistration_RefreshesUsingPersistedCredentials()
{
await using var app = await StartMcpServerAsync();
var tokenCache = new TestTokenCache();
var authDelegateCalledInitially = false;
// First "process": no ClientId is configured, so the client registers dynamically.
await using var setupTransport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new()
{
RedirectUri = new Uri("http://localhost:1179/callback"),
DynamicClientRegistration = new()
{
ClientName = "Test MCP Client",
ClientUri = new Uri("https://example.com"),
},
AuthorizationCallbackHandler = (context, ct) =>
{
authDelegateCalledInitially = true;
return HandleAuthorizationUrlAsync(context, ct);
},
TokenCache = tokenCache,
},
}, HttpClient, LoggerFactory);
await using (var setupClient = await McpClient.CreateAsync(setupTransport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken))
{
// Just connecting should trigger dynamic registration, authorization, and storage.
}
Assert.True(authDelegateCalledInitially, "Authorization callback should be called for the initial authorization");
Assert.False(TestOAuthServer.HasRefreshedToken, "Token should not have been refreshed yet");
Assert.NotNull(tokenCache.LastStoredToken);
Assert.False(
string.IsNullOrEmpty(tokenCache.LastStoredToken.ClientId),
"The dynamically registered client ID should be persisted alongside the tokens");
Assert.Equal(OAuthServerUrl, tokenCache.LastStoredToken.AuthorizationServer);
// Simulate a cold start: the access token is no longer valid, but the refresh token persists.
// The new provider has no client ID configured and must restore it from the cache to refresh
// instead of throwing "Client ID is not available".
tokenCache.LastStoredToken.AccessToken = "invalid-token";
var authDelegateCalledAgain = false;
await using var transport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new()
{
RedirectUri = new Uri("http://localhost:1179/callback"),
DynamicClientRegistration = new()
{
ClientName = "Test MCP Client",
ClientUri = new Uri("https://example.com"),
},
AuthorizationCallbackHandler = (context, ct) =>
{
authDelegateCalledAgain = true;
return HandleAuthorizationUrlAsync(context, ct);
},
TokenCache = tokenCache,
},
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
Assert.False(authDelegateCalledAgain, "Authorization callback should not be called when the persisted refresh token can be used");
Assert.True(TestOAuthServer.HasRefreshedToken, "Token should have been refreshed using the persisted client credentials");
Assert.NotEqual("invalid-token", tokenCache.LastStoredToken.AccessToken);
}
[Fact]
public async Task GetTokenAsync_ColdStartWithCredentialsFromDifferentAuthorizationServer_Reregisters()
{
await using var app = await StartMcpServerAsync();
var tokenCache = new TestTokenCache();
await using var setupTransport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new()
{
RedirectUri = new Uri("http://localhost:1179/callback"),
DynamicClientRegistration = new() { ClientName = "Test MCP Client" },
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
TokenCache = tokenCache,
},
}, HttpClient, LoggerFactory);
await using (var setupClient = await McpClient.CreateAsync(
setupTransport,
loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken))
{
}
Assert.NotNull(tokenCache.LastStoredToken);
tokenCache.LastStoredToken.AccessToken = "invalid-token";
tokenCache.LastStoredToken.AuthorizationServer = "https://different-authorization-server.example.com";
var authorizationCallbackCalled = false;
await using var transport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new()
{
RedirectUri = new Uri("http://localhost:1179/callback"),
DynamicClientRegistration = new() { ClientName = "Test MCP Client" },
AuthorizationCallbackHandler = (context, ct) =>
{
authorizationCallbackCalled = true;
return HandleAuthorizationUrlAsync(context, ct);
},
TokenCache = tokenCache,
},
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(
transport,
loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken);
Assert.True(authorizationCallbackCalled);
Assert.False(TestOAuthServer.HasRefreshedToken);
Assert.Equal(OAuthServerUrl, tokenCache.LastStoredToken.AuthorizationServer);
}
[Fact]
public async Task GetTokenAsync_ColdStartWithLegacyUnboundCredentials_Reregisters()
{
await using var app = await StartMcpServerAsync();
var tokenCache = new TestTokenCache();
await using var setupTransport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new()
{
RedirectUri = new Uri("http://localhost:1179/callback"),
DynamicClientRegistration = new() { ClientName = "Test MCP Client" },
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
TokenCache = tokenCache,
},
}, HttpClient, LoggerFactory);
await using (var setupClient = await McpClient.CreateAsync(
setupTransport,
loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken))
{
}
Assert.NotNull(tokenCache.LastStoredToken);
Assert.False(string.IsNullOrEmpty(tokenCache.LastStoredToken.ClientId));
tokenCache.LastStoredToken.AccessToken = "invalid-token";
tokenCache.LastStoredToken.AuthorizationServer = null;
var authorizationCallbackCalled = false;
await using var transport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new()
{
RedirectUri = new Uri("http://localhost:1179/callback"),
DynamicClientRegistration = new() { ClientName = "Test MCP Client" },
AuthorizationCallbackHandler = (context, ct) =>
{
authorizationCallbackCalled = true;
return HandleAuthorizationUrlAsync(context, ct);
},
TokenCache = tokenCache,
},
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(
transport,
loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken);
Assert.True(authorizationCallbackCalled);
Assert.False(TestOAuthServer.HasRefreshedToken);
Assert.Equal(OAuthServerUrl, tokenCache.LastStoredToken.AuthorizationServer);
}
[Fact]
public async Task GetTokenAsync_ColdStartWithoutPersistedClientId_FallsBackToReauthorization()
{
await using var app = await StartMcpServerAsync();
var tokenCache = new TestTokenCache();
var authDelegateCalledInitially = false;
await using var setupTransport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new()
{
RedirectUri = new Uri("http://localhost:1179/callback"),
DynamicClientRegistration = new()
{
ClientName = "Test MCP Client",
ClientUri = new Uri("https://example.com"),
},
AuthorizationCallbackHandler = (context, ct) =>
{
authDelegateCalledInitially = true;
return HandleAuthorizationUrlAsync(context, ct);
},
TokenCache = tokenCache,
},
}, HttpClient, LoggerFactory);
await using (var setupClient = await McpClient.CreateAsync(setupTransport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken))
{
}
Assert.True(authDelegateCalledInitially, "Authorization callback should be called for the initial authorization");
Assert.NotNull(tokenCache.LastStoredToken);
// Simulate a durable cache that persisted tokens but not the client registration (for example
// an entry written by an older version). On a cold start the provider cannot refresh without a
// client ID and must fall back to a fresh authorization rather than throwing.
tokenCache.LastStoredToken.AccessToken = "invalid-token";
tokenCache.LastStoredToken.ClientId = null;
tokenCache.LastStoredToken.ClientSecret = null;
tokenCache.LastStoredToken.TokenEndpointAuthMethod = null;
var authDelegateCalledAgain = false;
await using var transport = new HttpClientTransport(new()
{
Endpoint = new(McpServerUrl),
OAuth = new()
{
RedirectUri = new Uri("http://localhost:1179/callback"),
DynamicClientRegistration = new()
{
ClientName = "Test MCP Client",
ClientUri = new Uri("https://example.com"),
},
AuthorizationCallbackHandler = (context, ct) =>
{
authDelegateCalledAgain = true;
return HandleAuthorizationUrlAsync(context, ct);
},
TokenCache = tokenCache,
},
}, HttpClient, LoggerFactory);
// Should not throw "Client ID is not available"; instead re-authorizes via dynamic
// registration and the authorization-code flow.
await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
Assert.True(authDelegateCalledAgain, "Authorization callback should be called to re-authorize when no client ID is available to refresh");
Assert.False(TestOAuthServer.HasRefreshedToken, "A refresh should not be attempted without a client ID");
}
private TokenContainer CreateInvalidToken()
{
return new TokenContainer
{
TokenType = "Bearer",
AccessToken = "invalid-token",
ObtainedAt = DateTimeOffset.UtcNow,
};
}
private class TestTokenCache(TokenContainer? initialToken = null) : ITokenCache
{
public TokenContainer? LastStoredToken { get; private set; } = initialToken;
public ValueTask<TokenContainer?> GetTokensAsync(CancellationToken cancellationToken)
{
return new ValueTask<TokenContainer?>(LastStoredToken);
}
public ValueTask StoreTokensAsync(TokenContainer tokens, CancellationToken cancellationToken)
{
LastStoredToken = tokens;
return ValueTask.CompletedTask;
}
}
}