Improvements to dynamic client registration (#609)
* Add a delegate for handling the dynamic client registration response. * Support passing an initial access token for dynamic client registration.
This commit is contained in:
parent
2fa658e439
commit
f9129f5b28
7 changed files with 102 additions and 28 deletions
|
|
@ -31,9 +31,12 @@ var transport = new SseClientTransport(new()
|
||||||
Name = "Secure Weather Client",
|
Name = "Secure Weather Client",
|
||||||
OAuth = new()
|
OAuth = new()
|
||||||
{
|
{
|
||||||
ClientName = "ProtectedMcpClient",
|
|
||||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||||
|
DynamicClientRegistration = new()
|
||||||
|
{
|
||||||
|
ClientName = "ProtectedMcpClient",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}, httpClient, consoleLoggerFactory);
|
}, httpClient, consoleLoggerFactory);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,22 +68,12 @@ public sealed class ClientOAuthOptions
|
||||||
public Func<IReadOnlyList<Uri>, Uri?>? AuthServerSelector { get; set; }
|
public Func<IReadOnlyList<Uri>, Uri?>? AuthServerSelector { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the client name to use during dynamic client registration.
|
/// Gets or sets the options to use during dynamic client registration.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// This is a human-readable name for the client that may be displayed to users during authorization.
|
|
||||||
/// Only used when a <see cref="ClientId"/> is not specified.
|
/// Only used when a <see cref="ClientId"/> is not specified.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public string? ClientName { get; set; }
|
public DynamicClientRegistrationOptions? DynamicClientRegistration { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the client URI to use during dynamic client registration.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// This should be a URL pointing to the client's home page or information page.
|
|
||||||
/// Only used when a <see cref="ClientId"/> is not specified.
|
|
||||||
/// </remarks>
|
|
||||||
public Uri? ClientUri { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets additional parameters to include in the query string of the OAuth authorization request
|
/// Gets or sets additional parameters to include in the query string of the OAuth authorization request
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using System.Collections.Specialized;
|
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
@ -28,9 +28,11 @@ internal sealed partial class ClientOAuthProvider
|
||||||
private readonly Func<IReadOnlyList<Uri>, Uri?> _authServerSelector;
|
private readonly Func<IReadOnlyList<Uri>, Uri?> _authServerSelector;
|
||||||
private readonly AuthorizationRedirectDelegate _authorizationRedirectDelegate;
|
private readonly AuthorizationRedirectDelegate _authorizationRedirectDelegate;
|
||||||
|
|
||||||
// _clientName and _client URI is used for dynamic client registration (RFC 7591)
|
// _dcrClientName, _dcrClientUri, _dcrInitialAccessToken and _dcrResponseDelegate are used for dynamic client registration (RFC 7591)
|
||||||
private readonly string? _clientName;
|
private readonly string? _dcrClientName;
|
||||||
private readonly Uri? _clientUri;
|
private readonly Uri? _dcrClientUri;
|
||||||
|
private readonly string? _dcrInitialAccessToken;
|
||||||
|
private readonly Func<DynamicClientRegistrationResponse, CancellationToken, Task>? _dcrResponseDelegate;
|
||||||
|
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
|
|
@ -66,9 +68,7 @@ internal sealed partial class ClientOAuthProvider
|
||||||
|
|
||||||
_clientId = options.ClientId;
|
_clientId = options.ClientId;
|
||||||
_clientSecret = options.ClientSecret;
|
_clientSecret = options.ClientSecret;
|
||||||
_redirectUri = options.RedirectUri ?? throw new ArgumentException("ClientOAuthOptions.RedirectUri must configured.");
|
_redirectUri = options.RedirectUri ?? throw new ArgumentException("ClientOAuthOptions.RedirectUri must configured.", nameof(options));
|
||||||
_clientName = options.ClientName;
|
|
||||||
_clientUri = options.ClientUri;
|
|
||||||
_scopes = options.Scopes?.ToArray();
|
_scopes = options.Scopes?.ToArray();
|
||||||
_additionalAuthorizationParameters = options.AdditionalAuthorizationParameters;
|
_additionalAuthorizationParameters = options.AdditionalAuthorizationParameters;
|
||||||
|
|
||||||
|
|
@ -77,6 +77,11 @@ internal sealed partial class ClientOAuthProvider
|
||||||
|
|
||||||
// Set up authorization URL handler (use default if not provided)
|
// Set up authorization URL handler (use default if not provided)
|
||||||
_authorizationRedirectDelegate = options.AuthorizationRedirectDelegate ?? DefaultAuthorizationUrlHandler;
|
_authorizationRedirectDelegate = options.AuthorizationRedirectDelegate ?? DefaultAuthorizationUrlHandler;
|
||||||
|
|
||||||
|
_dcrClientName = options.DynamicClientRegistration?.ClientName;
|
||||||
|
_dcrClientUri = options.DynamicClientRegistration?.ClientUri;
|
||||||
|
_dcrInitialAccessToken = options.DynamicClientRegistration?.InitialAccessToken;
|
||||||
|
_dcrResponseDelegate = options.DynamicClientRegistration?.ResponseDelegate;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -447,8 +452,8 @@ internal sealed partial class ClientOAuthProvider
|
||||||
GrantTypes = ["authorization_code", "refresh_token"],
|
GrantTypes = ["authorization_code", "refresh_token"],
|
||||||
ResponseTypes = ["code"],
|
ResponseTypes = ["code"],
|
||||||
TokenEndpointAuthMethod = "client_secret_post",
|
TokenEndpointAuthMethod = "client_secret_post",
|
||||||
ClientName = _clientName,
|
ClientName = _dcrClientName,
|
||||||
ClientUri = _clientUri?.ToString(),
|
ClientUri = _dcrClientUri?.ToString(),
|
||||||
Scope = _scopes is not null ? string.Join(" ", _scopes) : null
|
Scope = _scopes is not null ? string.Join(" ", _scopes) : null
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -460,6 +465,11 @@ internal sealed partial class ClientOAuthProvider
|
||||||
Content = requestContent
|
Content = requestContent
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(_dcrInitialAccessToken))
|
||||||
|
{
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue(BearerScheme, _dcrInitialAccessToken);
|
||||||
|
}
|
||||||
|
|
||||||
using var httpResponse = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
using var httpResponse = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
if (!httpResponse.IsSuccessStatusCode)
|
if (!httpResponse.IsSuccessStatusCode)
|
||||||
|
|
@ -487,6 +497,11 @@ internal sealed partial class ClientOAuthProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
LogDynamicClientRegistrationSuccessful(_clientId!);
|
LogDynamicClientRegistrationSuccessful(_clientId!);
|
||||||
|
|
||||||
|
if (_dcrResponseDelegate is not null)
|
||||||
|
{
|
||||||
|
await _dcrResponseDelegate(registrationResponse, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
namespace ModelContextProtocol.Authentication;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides configuration options for the <see cref="ClientOAuthProvider"/> related to dynamic client registration (RFC 7591).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DynamicClientRegistrationOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the client name to use during dynamic client registration.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This is a human-readable name for the client that may be displayed to users during authorization.
|
||||||
|
/// </remarks>
|
||||||
|
public string? ClientName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the client URI to use during dynamic client registration.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This should be a URL pointing to the client's home page or information page.
|
||||||
|
/// </remarks>
|
||||||
|
public Uri? ClientUri { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the initial access token to use during dynamic client registration.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This token is used to authenticate the client during the registration process.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// This is required if the authorization server does not allow anonymous client registration.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public string? InitialAccessToken { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the delegate used for handling the dynamic client registration response.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This delegate is responsible for processing the response from the dynamic client registration endpoint.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The implementation should save the client credentials securely for future use.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public Func<DynamicClientRegistrationResponse, CancellationToken, Task>? ResponseDelegate { get; set; }
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@ namespace ModelContextProtocol.Authentication;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents a client registration response for OAuth 2.0 Dynamic Client Registration (RFC 7591).
|
/// Represents a client registration response for OAuth 2.0 Dynamic Client Registration (RFC 7591).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class DynamicClientRegistrationResponse
|
public sealed class DynamicClientRegistrationResponse
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the client identifier.
|
/// Gets or sets the client identifier.
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,8 @@ public class AuthEventTests : KestrelInMemoryTest, IAsyncDisposable
|
||||||
|
|
||||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
DynamicClientRegistrationResponse? dcrResponse = null;
|
||||||
|
|
||||||
await using var transport = new SseClientTransport(
|
await using var transport = new SseClientTransport(
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
|
|
@ -148,9 +150,17 @@ public class AuthEventTests : KestrelInMemoryTest, IAsyncDisposable
|
||||||
{
|
{
|
||||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||||
ClientName = "Test MCP Client",
|
|
||||||
ClientUri = new Uri("https://example.com"),
|
|
||||||
Scopes = ["mcp:tools"],
|
Scopes = ["mcp:tools"],
|
||||||
|
DynamicClientRegistration = new()
|
||||||
|
{
|
||||||
|
ClientName = "Test MCP Client",
|
||||||
|
ClientUri = new Uri("https://example.com"),
|
||||||
|
ResponseDelegate = (response, cancellationToken) =>
|
||||||
|
{
|
||||||
|
dcrResponse = response;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
HttpClient,
|
HttpClient,
|
||||||
|
|
@ -162,6 +172,10 @@ public class AuthEventTests : KestrelInMemoryTest, IAsyncDisposable
|
||||||
loggerFactory: LoggerFactory,
|
loggerFactory: LoggerFactory,
|
||||||
cancellationToken: TestContext.Current.CancellationToken
|
cancellationToken: TestContext.Current.CancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Assert.NotNull(dcrResponse);
|
||||||
|
Assert.False(string.IsNullOrEmpty(dcrResponse.ClientId));
|
||||||
|
Assert.False(string.IsNullOrEmpty(dcrResponse.ClientSecret));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -181,9 +181,12 @@ public class AuthTests : KestrelInMemoryTest, IAsyncDisposable
|
||||||
{
|
{
|
||||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||||
ClientName = "Test MCP Client",
|
Scopes = ["mcp:tools"],
|
||||||
ClientUri = new Uri("https://example.com"),
|
DynamicClientRegistration = new()
|
||||||
Scopes = ["mcp:tools"]
|
{
|
||||||
|
ClientName = "Test MCP Client",
|
||||||
|
ClientUri = new Uri("https://example.com"),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}, HttpClient, LoggerFactory);
|
}, HttpClient, LoggerFactory);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue