Fix ClientOAuthProvider "Client ID is not available" on cold start (#1705)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
9df7640172
commit
2d1b9a3462
1 changed files with 238 additions and 3 deletions
|
|
@ -187,6 +187,152 @@ public class TokenCacheTests : OAuthTestBase
|
|||
Assert.NotEqual("invalid-token", tokenCache.LastStoredToken.AccessToken);
|
||||
}
|
||||
|
||||
[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");
|
||||
|
||||
// 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_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
|
||||
|
|
|
|||
Loading…
Reference in a new issue