Add application_type to the Dynamic Client Registration request (#1613)
This commit is contained in:
parent
28806b1b6e
commit
20c464c55b
8 changed files with 223 additions and 3 deletions
|
|
@ -34,10 +34,11 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
private readonly AuthorizationRedirectDelegate _authorizationRedirectDelegate;
|
private readonly AuthorizationRedirectDelegate _authorizationRedirectDelegate;
|
||||||
private readonly Uri? _clientMetadataDocumentUri;
|
private readonly Uri? _clientMetadataDocumentUri;
|
||||||
|
|
||||||
// _dcrClientName, _dcrClientUri, _dcrInitialAccessToken and _dcrResponseDelegate are used for dynamic client registration (RFC 7591)
|
// _dcrClientName, _dcrClientUri, _dcrInitialAccessToken, _dcrConfiguredApplicationType and _dcrResponseDelegate are used for dynamic client registration (RFC 7591)
|
||||||
private readonly string? _dcrClientName;
|
private readonly string? _dcrClientName;
|
||||||
private readonly Uri? _dcrClientUri;
|
private readonly Uri? _dcrClientUri;
|
||||||
private readonly string? _dcrInitialAccessToken;
|
private readonly string? _dcrInitialAccessToken;
|
||||||
|
private readonly string? _dcrConfiguredApplicationType;
|
||||||
private readonly Func<DynamicClientRegistrationResponse, CancellationToken, Task>? _dcrResponseDelegate;
|
private readonly Func<DynamicClientRegistrationResponse, CancellationToken, Task>? _dcrResponseDelegate;
|
||||||
|
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
|
|
@ -110,6 +111,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
_dcrClientUri = options.DynamicClientRegistration?.ClientUri;
|
_dcrClientUri = options.DynamicClientRegistration?.ClientUri;
|
||||||
_dcrInitialAccessToken = options.DynamicClientRegistration?.InitialAccessToken;
|
_dcrInitialAccessToken = options.DynamicClientRegistration?.InitialAccessToken;
|
||||||
_dcrResponseDelegate = options.DynamicClientRegistration?.ResponseDelegate;
|
_dcrResponseDelegate = options.DynamicClientRegistration?.ResponseDelegate;
|
||||||
|
_dcrConfiguredApplicationType = options.DynamicClientRegistration?.ApplicationType;
|
||||||
_tokenCache = options.TokenCache ?? new InMemoryTokenCache();
|
_tokenCache = options.TokenCache ?? new InMemoryTokenCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -788,6 +790,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
|
|
||||||
LogPerformingDynamicClientRegistration(authServerMetadata.RegistrationEndpoint);
|
LogPerformingDynamicClientRegistration(authServerMetadata.RegistrationEndpoint);
|
||||||
|
|
||||||
|
var dcrApplicationType = ResolveApplicationType(_dcrConfiguredApplicationType, _redirectUri);
|
||||||
var registrationRequest = new DynamicClientRegistrationRequest
|
var registrationRequest = new DynamicClientRegistrationRequest
|
||||||
{
|
{
|
||||||
RedirectUris = [_redirectUri.ToString()],
|
RedirectUris = [_redirectUri.ToString()],
|
||||||
|
|
@ -797,6 +800,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
ClientName = _dcrClientName,
|
ClientName = _dcrClientName,
|
||||||
ClientUri = _dcrClientUri?.ToString(),
|
ClientUri = _dcrClientUri?.ToString(),
|
||||||
Scope = ComputeEffectiveScope(protectedResourceMetadata, authServerMetadata),
|
Scope = ComputeEffectiveScope(protectedResourceMetadata, authServerMetadata),
|
||||||
|
ApplicationType = dcrApplicationType,
|
||||||
};
|
};
|
||||||
|
|
||||||
var requestBytes = JsonSerializer.SerializeToUtf8Bytes(registrationRequest, McpJsonUtilities.JsonContext.Default.DynamicClientRegistrationRequest);
|
var requestBytes = JsonSerializer.SerializeToUtf8Bytes(registrationRequest, McpJsonUtilities.JsonContext.Default.DynamicClientRegistrationRequest);
|
||||||
|
|
@ -818,7 +822,9 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
if (!httpResponse.IsSuccessStatusCode)
|
if (!httpResponse.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
var errorContent = await httpResponse.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
var errorContent = await httpResponse.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||||
ThrowFailedToHandleUnauthorizedResponse($"Dynamic client registration failed with status {httpResponse.StatusCode}: {errorContent}");
|
ThrowFailedToHandleUnauthorizedResponse(
|
||||||
|
$"Dynamic client registration failed with status {httpResponse.StatusCode}: {errorContent} " +
|
||||||
|
$"(application_type: '{dcrApplicationType}', redirect_uri: '{_redirectUri}').");
|
||||||
}
|
}
|
||||||
|
|
||||||
using var responseStream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
using var responseStream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
@ -852,6 +858,18 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string ResolveApplicationType(string? configuredApplicationType, Uri redirectUri)
|
||||||
|
=> configuredApplicationType ?? InferApplicationType(redirectUri);
|
||||||
|
|
||||||
|
private static string InferApplicationType(Uri redirectUri)
|
||||||
|
{
|
||||||
|
if (redirectUri.Scheme is "http" or "https")
|
||||||
|
{
|
||||||
|
return redirectUri.IsLoopback ? "native" : "web";
|
||||||
|
}
|
||||||
|
return "native";
|
||||||
|
}
|
||||||
|
|
||||||
private static string? GetResourceUri(ProtectedResourceMetadata protectedResourceMetadata)
|
private static string? GetResourceUri(ProtectedResourceMetadata protectedResourceMetadata)
|
||||||
=> protectedResourceMetadata.Resource;
|
=> protectedResourceMetadata.Resource;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,24 @@ public sealed class DynamicClientRegistrationOptions
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public string? InitialAccessToken { get; set; }
|
public string? InitialAccessToken { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the OIDC <c>application_type</c> sent during dynamic client registration.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// When <see langword="null"/>, the SDK infers the value from the configured
|
||||||
|
/// <see cref="ClientOAuthOptions.RedirectUri"/>: loopback hosts (<c>localhost</c>,
|
||||||
|
/// <c>127.0.0.1</c>, <c>[::1]</c>) and custom-scheme URIs map to <c>"native"</c>; remote
|
||||||
|
/// <c>http://</c> and <c>https://</c> URIs map to <c>"web"</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// When set explicitly, the value is sent without modification. Use this to account for
|
||||||
|
/// authorization-server-specific requirements or to retry a registration with an adjusted
|
||||||
|
/// application type.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public string? ApplicationType { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the delegate used for handling the dynamic client registration response.
|
/// Gets or sets the delegate used for handling the dynamic client registration response.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -48,4 +48,10 @@ internal sealed class DynamicClientRegistrationRequest
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[JsonPropertyName("scope")]
|
[JsonPropertyName("scope")]
|
||||||
public string? Scope { get; init; }
|
public string? Scope { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the OIDC application type ("native" or "web") for the client.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("application_type")]
|
||||||
|
public string? ApplicationType { get; init; }
|
||||||
}
|
}
|
||||||
|
|
@ -110,6 +110,33 @@ public class AuthTests : OAuthTestBase
|
||||||
|
|
||||||
await using var client = await McpClient.CreateAsync(
|
await using var client = await McpClient.CreateAsync(
|
||||||
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal("native", TestOAuthServer.LastApplicationType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DynamicClientRegistration_UsesExplicitApplicationType()
|
||||||
|
{
|
||||||
|
await using var app = await StartMcpServerAsync();
|
||||||
|
|
||||||
|
await using var transport = new HttpClientTransport(new()
|
||||||
|
{
|
||||||
|
Endpoint = new(McpServerUrl),
|
||||||
|
OAuth = new ClientOAuthOptions()
|
||||||
|
{
|
||||||
|
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||||
|
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||||
|
DynamicClientRegistration = new()
|
||||||
|
{
|
||||||
|
ApplicationType = "web",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, HttpClient, LoggerFactory);
|
||||||
|
|
||||||
|
await using var client = await McpClient.CreateAsync(
|
||||||
|
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal("web", TestOAuthServer.LastApplicationType);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -124,7 +151,11 @@ public class AuthTests : OAuthTestBase
|
||||||
{
|
{
|
||||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||||
ClientMetadataDocumentUri = new Uri(ClientMetadataDocumentUrl)
|
ClientMetadataDocumentUri = new Uri(ClientMetadataDocumentUrl),
|
||||||
|
DynamicClientRegistration = new()
|
||||||
|
{
|
||||||
|
ApplicationType = "web",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}, HttpClient, LoggerFactory);
|
}, HttpClient, LoggerFactory);
|
||||||
|
|
||||||
|
|
@ -258,6 +289,10 @@ public class AuthTests : OAuthTestBase
|
||||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||||
ClientMetadataDocumentUri = new Uri("http://invalid-cimd.example.com"),
|
ClientMetadataDocumentUri = new Uri("http://invalid-cimd.example.com"),
|
||||||
|
DynamicClientRegistration = new()
|
||||||
|
{
|
||||||
|
ApplicationType = "web",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}, HttpClient, LoggerFactory);
|
}, HttpClient, LoggerFactory);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
using ModelContextProtocol.Authentication;
|
||||||
|
using ModelContextProtocol.Client;
|
||||||
|
|
||||||
|
namespace ModelContextProtocol.AspNetCore.Tests.OAuth;
|
||||||
|
|
||||||
|
// SEP-837: the SDK doesn't surface or retry DCR failures itself, but a consumer built on the SDK
|
||||||
|
// must be able to. These tests prove that surface: a rejected registration propagates with enough
|
||||||
|
// context to build a meaningful error, and a consumer can retry with an adjusted redirect URI.
|
||||||
|
public class DcrFailureTests : OAuthTestBase
|
||||||
|
{
|
||||||
|
public DcrFailureTests(ITestOutputHelper outputHelper)
|
||||||
|
: base(outputHelper)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DcrRejection_PropagatesToConsumer_WithStatusBodyAndSentParameters()
|
||||||
|
{
|
||||||
|
await using var app = await StartMcpServerAsync();
|
||||||
|
|
||||||
|
// A custom-scheme redirect URI infers application_type "native"; the OIDC AS rejects it
|
||||||
|
// with 400 invalid_redirect_uri because it only registers http/https redirect URIs.
|
||||||
|
await using var transport = new HttpClientTransport(new()
|
||||||
|
{
|
||||||
|
Endpoint = new(McpServerUrl),
|
||||||
|
OAuth = new ClientOAuthOptions()
|
||||||
|
{
|
||||||
|
RedirectUri = new Uri("myapp://callback"),
|
||||||
|
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||||
|
DynamicClientRegistration = new() { ClientName = "Test MCP Client" },
|
||||||
|
},
|
||||||
|
}, HttpClient, LoggerFactory);
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<McpException>(() => McpClient.CreateAsync(
|
||||||
|
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
|
// The consumer needs enough to produce a meaningful error: the HTTP status, the AS error
|
||||||
|
// body (which echoes the redirect URI), and the application_type the SDK actually sent.
|
||||||
|
Assert.Contains("BadRequest", ex.Message);
|
||||||
|
Assert.Contains("invalid_redirect_uri", ex.Message);
|
||||||
|
Assert.Contains("native", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ConsumerCanRetryRegistration_WithAdjustedRedirectUri_AfterRejection()
|
||||||
|
{
|
||||||
|
await using var app = await StartMcpServerAsync();
|
||||||
|
|
||||||
|
// First attempt: a custom-scheme redirect (native) is rejected by the AS. ApplicationType
|
||||||
|
// is held constant at "native" so only the redirect URI changes between the two attempts.
|
||||||
|
await using var firstTransport = new HttpClientTransport(new()
|
||||||
|
{
|
||||||
|
Endpoint = new(McpServerUrl),
|
||||||
|
OAuth = new ClientOAuthOptions()
|
||||||
|
{
|
||||||
|
RedirectUri = new Uri("myapp://callback"),
|
||||||
|
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||||
|
DynamicClientRegistration = new()
|
||||||
|
{
|
||||||
|
ClientName = "Test MCP Client",
|
||||||
|
ApplicationType = "native",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, HttpClient, LoggerFactory);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<McpException>(() => McpClient.CreateAsync(
|
||||||
|
firstTransport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
|
// Second attempt: a new provider on the SAME HttpClient with an adjusted (loopback) redirect
|
||||||
|
// URI that the AS accepts. The retry must succeed, proving the SEP-837 MAY-retry surface works
|
||||||
|
// and that the rejected attempt left no client state behind.
|
||||||
|
await using var secondTransport = new HttpClientTransport(new()
|
||||||
|
{
|
||||||
|
Endpoint = new(McpServerUrl),
|
||||||
|
OAuth = new ClientOAuthOptions()
|
||||||
|
{
|
||||||
|
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||||
|
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||||
|
DynamicClientRegistration = new()
|
||||||
|
{
|
||||||
|
ClientName = "Test MCP Client",
|
||||||
|
ApplicationType = "native",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, HttpClient, LoggerFactory);
|
||||||
|
|
||||||
|
await using var client = await McpClient.CreateAsync(
|
||||||
|
secondTransport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal("native", TestOAuthServer.LastApplicationType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -55,6 +55,12 @@ internal sealed class ClientRegistrationRequest
|
||||||
[JsonPropertyName("scope")]
|
[JsonPropertyName("scope")]
|
||||||
public string? Scope { get; init; }
|
public string? Scope { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the OIDC application type ("web" or "native").
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("application_type")]
|
||||||
|
public string? ApplicationType { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the contacts for the client.
|
/// Gets or sets the contacts for the client.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,9 @@ public sealed class Program
|
||||||
/// <summary>Gets the <c>scope</c> field from the most recent Dynamic Client Registration request.</summary>
|
/// <summary>Gets the <c>scope</c> field from the most recent Dynamic Client Registration request.</summary>
|
||||||
public string? LastRegistrationScope { get; private set; }
|
public string? LastRegistrationScope { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Gets the <c>application_type</c> field from the most recent Dynamic Client Registration request.</summary>
|
||||||
|
public string? LastApplicationType { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Entry point for the application.
|
/// Entry point for the application.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -681,6 +684,7 @@ public sealed class Program
|
||||||
}
|
}
|
||||||
|
|
||||||
LastRegistrationScope = registrationRequest.Scope;
|
LastRegistrationScope = registrationRequest.Scope;
|
||||||
|
LastApplicationType = registrationRequest.ApplicationType;
|
||||||
|
|
||||||
// Validate redirect URIs are provided
|
// Validate redirect URIs are provided
|
||||||
if (registrationRequest.RedirectUris.Count == 0)
|
if (registrationRequest.RedirectUris.Count == 0)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
using ModelContextProtocol.Authentication;
|
||||||
|
using ModelContextProtocol.Client;
|
||||||
|
|
||||||
|
namespace ModelContextProtocol.Tests.Authentication;
|
||||||
|
|
||||||
|
// ClientOAuthProvider is internal; construct it indirectly via HttpClientTransport
|
||||||
|
// to verify DCR application_type validation is deferred until DCR is selected.
|
||||||
|
public class ClientOAuthProviderApplicationTypeTests
|
||||||
|
{
|
||||||
|
private static readonly Uri ServerEndpoint = new("https://server.example.com/mcp");
|
||||||
|
|
||||||
|
private static HttpClientTransportOptions BuildOptions(string redirectUri, string? explicitApplicationType = null)
|
||||||
|
{
|
||||||
|
return new HttpClientTransportOptions
|
||||||
|
{
|
||||||
|
Endpoint = ServerEndpoint,
|
||||||
|
OAuth = new ClientOAuthOptions
|
||||||
|
{
|
||||||
|
RedirectUri = new Uri(redirectUri),
|
||||||
|
DynamicClientRegistration = new DynamicClientRegistrationOptions
|
||||||
|
{
|
||||||
|
ApplicationType = explicitApplicationType,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("http://localhost:8080/callback", "web")]
|
||||||
|
[InlineData("https://example.com/callback", "native")]
|
||||||
|
public void Constructor_Defers_ApplicationTypeValidation_UntilDynamicRegistration(
|
||||||
|
string redirectUri, string explicitType)
|
||||||
|
{
|
||||||
|
var options = BuildOptions(redirectUri, explicitType);
|
||||||
|
|
||||||
|
using var httpClient = new HttpClient();
|
||||||
|
_ = new HttpClientTransport(options, httpClient);
|
||||||
|
|
||||||
|
Assert.Equal(explicitType, options.OAuth!.DynamicClientRegistration!.ApplicationType);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue