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:
S-Luiten 2025-08-26 23:24:00 +02:00 • committed by GitHub
commit f9129f5b28
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 102 additions and 28 deletions

View file

@ -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);

View file

@ -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

View file

@ -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>

View file

@ -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; }
}

View file

@ -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.

View file

@ -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]

View file

@ -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);