Skip to content

Commit 5a65af8

Browse files
Add support for Client ID Metadata Documents to enable URL-based client registration (#1023)
1 parent 7f25ae9 commit 5a65af8

File tree

8 files changed

+202
-11
lines changed

8 files changed

+202
-11
lines changed

src/ModelContextProtocol.Core/Authentication/AuthorizationServerMetadata.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,4 +66,10 @@ internal sealed class AuthorizationServerMetadata
6666
/// </summary>
6767
[JsonPropertyName("scopes_supported")]
6868
public List<string>? ScopesSupported { get; set; }
69+
70+
/// <summary>
71+
/// Indicates if the server supports OAuth Client ID Metadata Documents.
72+
/// </summary>
73+
[JsonPropertyName("client_id_metadata_document_supported")]
74+
public bool ClientIdMetadataDocumentSupported { get; set; }
6975
}

src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,17 @@ public sealed class ClientOAuthOptions
2323
/// </remarks>
2424
public string? ClientSecret { get; set; }
2525

26+
/// <summary>
27+
/// Gets or sets the HTTPS URL pointing to this client's metadata document.
28+
/// </summary>
29+
/// <remarks>
30+
/// When specified, and when the authorization server metadata reports
31+
/// <c>client_id_metadata_document_supported = true</c>, the OAuth client will respond to
32+
/// challenges by sending this URL as the client identifier instead of performing dynamic
33+
/// client registration.
34+
/// </remarks>
35+
public Uri? ClientMetadataDocumentUri { get; set; }
36+
2637
/// <summary>
2738
/// Gets or sets the OAuth scopes to request.
2839
/// </summary>

src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ internal sealed partial class ClientOAuthProvider
3030
private readonly IDictionary<string, string> _additionalAuthorizationParameters;
3131
private readonly Func<IReadOnlyList<Uri>, Uri?> _authServerSelector;
3232
private readonly AuthorizationRedirectDelegate _authorizationRedirectDelegate;
33+
private readonly Uri? _clientMetadataDocumentUri;
3334

3435
// _dcrClientName, _dcrClientUri, _dcrInitialAccessToken and _dcrResponseDelegate are used for dynamic client registration (RFC 7591)
3536
private readonly string? _dcrClientName;
@@ -74,6 +75,7 @@ public ClientOAuthProvider(
7475
_redirectUri = options.RedirectUri ?? throw new ArgumentException("ClientOAuthOptions.RedirectUri must configured.", nameof(options));
7576
_scopes = options.Scopes?.ToArray();
7677
_additionalAuthorizationParameters = options.AdditionalAuthorizationParameters;
78+
_clientMetadataDocumentUri = options.ClientMetadataDocumentUri;
7779

7880
// Set up authorization server selection strategy
7981
_authServerSelector = options.AuthServerSelector ?? DefaultAuthServerSelector;
@@ -221,7 +223,7 @@ private async Task PerformOAuthAuthorizationAsync(
221223
_authServerMetadata = authServerMetadata;
222224

223225
// The existing access token must be invalid to have resulted in a 401 response, but refresh might still work.
224-
if (await _tokenCache.GetTokensAsync(cancellationToken).ConfigureAwait(false) is { RefreshToken: {} refreshToken })
226+
if (await _tokenCache.GetTokensAsync(cancellationToken).ConfigureAwait(false) is { RefreshToken: { } refreshToken })
225227
{
226228
var refreshedTokens = await RefreshTokenAsync(refreshToken, protectedResourceMetadata.Resource, authServerMetadata, cancellationToken).ConfigureAwait(false);
227229
if (refreshedTokens is not null)
@@ -231,10 +233,18 @@ private async Task PerformOAuthAuthorizationAsync(
231233
}
232234
}
233235

234-
// Perform dynamic client registration if needed
236+
// Assign a client ID if necessary
235237
if (string.IsNullOrEmpty(_clientId))
236238
{
237-
await PerformDynamicClientRegistrationAsync(authServerMetadata, cancellationToken).ConfigureAwait(false);
239+
// Try using a client metadata document before falling back to dynamic client registration
240+
if (authServerMetadata.ClientIdMetadataDocumentSupported && _clientMetadataDocumentUri is not null)
241+
{
242+
ApplyClientIdMetadataDocument(_clientMetadataDocumentUri);
243+
}
244+
else
245+
{
246+
await PerformDynamicClientRegistrationAsync(authServerMetadata, cancellationToken).ConfigureAwait(false);
247+
}
238248
}
239249

240250
// Perform the OAuth flow
@@ -243,6 +253,23 @@ private async Task PerformOAuthAuthorizationAsync(
243253
LogOAuthAuthorizationCompleted();
244254
}
245255

256+
private void ApplyClientIdMetadataDocument(Uri metadataUri)
257+
{
258+
if (!IsValidClientMetadataDocumentUri(metadataUri))
259+
{
260+
ThrowFailedToHandleUnauthorizedResponse(
261+
$"{nameof(ClientOAuthOptions.ClientMetadataDocumentUri)} must be an HTTPS URL with a non-root absolute path. Value: '{metadataUri}'.");
262+
}
263+
264+
_clientId = metadataUri.AbsoluteUri;
265+
266+
// See: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00#section-3
267+
static bool IsValidClientMetadataDocumentUri(Uri uri)
268+
=> uri.IsAbsoluteUri
269+
&& string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)
270+
&& uri.AbsolutePath.Length > 1; // AbsolutePath always starts with "/"
271+
}
272+
246273
private async Task<AuthorizationServerMetadata> GetAuthServerMetadataAsync(Uri authServerUri, CancellationToken cancellationToken)
247274
{
248275
if (authServerUri.OriginalString.Length == 0 ||

tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs

Lines changed: 103 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ namespace ModelContextProtocol.AspNetCore.Tests.OAuth;
99

1010
public class AuthTests : OAuthTestBase
1111
{
12-
public AuthTests(ITestOutputHelper outputHelper)
13-
: base(outputHelper)
12+
private const string ClientMetadataDocumentUrl = $"{OAuthServerUrl}/client-metadata/cimd-client.json";
13+
14+
public AuthTests(ITestOutputHelper outputHelper)
15+
: base(outputHelper)
1416
{
1517
}
1618

@@ -98,6 +100,105 @@ public async Task CanAuthenticate_WithDynamicClientRegistration()
98100
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
99101
}
100102

103+
[Fact]
104+
public async Task CanAuthenticate_WithClientMetadataDocument()
105+
{
106+
await using var app = await StartMcpServerAsync();
107+
108+
await using var transport = new HttpClientTransport(new()
109+
{
110+
Endpoint = new(McpServerUrl),
111+
OAuth = new ClientOAuthOptions()
112+
{
113+
RedirectUri = new Uri("http://localhost:1179/callback"),
114+
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
115+
ClientMetadataDocumentUri = new Uri(ClientMetadataDocumentUrl)
116+
},
117+
}, HttpClient, LoggerFactory);
118+
119+
await using var client = await McpClient.CreateAsync(
120+
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
121+
}
122+
123+
[Fact]
124+
public async Task UsesDynamicClientRegistration_WhenCimdNotSupported()
125+
{
126+
// Disable CIMD support on the test OAuth server so the client
127+
// falls back to dynamic registration even if a CIMD URL is provided.
128+
TestOAuthServer.ClientIdMetadataDocumentSupported = false;
129+
130+
await using var app = await StartMcpServerAsync();
131+
132+
// Provide an invalid CIMD URL; if CIMD were used, auth would fail.
133+
await using var transport = new HttpClientTransport(new()
134+
{
135+
Endpoint = new(McpServerUrl),
136+
OAuth = new ClientOAuthOptions()
137+
{
138+
RedirectUri = new Uri("http://localhost:1179/callback"),
139+
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
140+
ClientMetadataDocumentUri = new Uri("http://invalid-cimd.example.com"),
141+
Scopes = ["mcp:tools"],
142+
DynamicClientRegistration = new()
143+
{
144+
ClientName = "Test MCP Client (No CIMD)",
145+
ClientUri = new Uri("https://example.com/no-cimd"),
146+
},
147+
},
148+
}, HttpClient, LoggerFactory);
149+
150+
// Should succeed via dynamic client registration.
151+
await using var client = await McpClient.CreateAsync(
152+
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
153+
}
154+
155+
[Fact]
156+
public async Task DoesNotUseClientMetadataDocument_WhenClientIdIsSpecified()
157+
{
158+
await using var app = await StartMcpServerAsync();
159+
160+
// Provide an invalid CIMD URL; if CIMD were used, auth would fail.
161+
await using var transport = new HttpClientTransport(new()
162+
{
163+
Endpoint = new(McpServerUrl),
164+
OAuth = new ClientOAuthOptions()
165+
{
166+
ClientId = "demo-client",
167+
ClientSecret = "demo-secret",
168+
RedirectUri = new Uri("http://localhost:1179/callback"),
169+
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
170+
ClientMetadataDocumentUri = new Uri("http://invalid-cimd.example.com"),
171+
},
172+
}, HttpClient, LoggerFactory);
173+
174+
await using var client = await McpClient.CreateAsync(
175+
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
176+
}
177+
178+
[Theory]
179+
[InlineData("http://localhost:7029/client-metadata/cimd-client.json")] // Non-HTTPS Scheme
180+
[InlineData("http://localhost:7029")] // Missing path
181+
public async Task CannotAuthenticate_WithInvalidClientMetadataDocument(string uri)
182+
{
183+
await using var app = await StartMcpServerAsync();
184+
185+
await using var transport = new HttpClientTransport(new()
186+
{
187+
Endpoint = new(McpServerUrl),
188+
OAuth = new ClientOAuthOptions()
189+
{
190+
RedirectUri = new Uri("http://localhost:1179/callback"),
191+
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
192+
ClientMetadataDocumentUri = new Uri(uri),
193+
},
194+
}, HttpClient, LoggerFactory);
195+
196+
var ex = await Assert.ThrowsAsync<McpException>(() => McpClient.CreateAsync(
197+
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
198+
199+
Assert.StartsWith("Failed to handle unauthorized response", ex.Message);
200+
}
201+
101202
[Fact]
102203
public async Task CanAuthenticate_WithTokenRefresh()
103204
{

tests/ModelContextProtocol.TestOAuthServer/AuthorizationServerMetadata.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,10 @@ internal sealed class AuthorizationServerMetadata
6060
/// </summary>
6161
[JsonPropertyName("scopes_supported")]
6262
public List<string>? ScopesSupported { get; init; }
63+
64+
/// <summary>
65+
/// Gets or sets a value indicating whether CIMD client IDs are supported.
66+
/// </summary>
67+
[JsonPropertyName("client_id_metadata_document_supported")]
68+
public bool ClientIdMetadataDocumentSupported { get; init; }
6369
}

tests/ModelContextProtocol.TestOAuthServer/ClientInfo.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,15 @@ internal sealed class ClientInfo
1212
/// </summary>
1313
public required string ClientId { get; init; }
1414

15+
/// <summary>
16+
/// Gets or sets whether a client secret is required.
17+
/// </summary>
18+
public required bool RequiresClientSecret { get; init; }
19+
1520
/// <summary>
1621
/// Gets or sets the client secret.
1722
/// </summary>
18-
public required string ClientSecret { get; init; }
23+
public string? ClientSecret { get; init; }
1924

2025
/// <summary>
2126
/// Gets or sets the list of redirect URIs allowed for this client.

tests/ModelContextProtocol.TestOAuthServer/OAuthServerMetadata.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,4 +171,11 @@ internal sealed class OAuthServerMetadata
171171
[JsonPropertyName("claims_supported")]
172172
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
173173
public List<string>? ClaimsSupported { get; init; }
174+
175+
/// <summary>
176+
/// Gets or sets a value indicating whether CIMD client IDs are supported.
177+
/// </summary>
178+
[JsonPropertyName("client_id_metadata_document_supported")]
179+
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
180+
public bool? ClientIdMetadataDocumentSupported { get; init; }
174181
}

tests/ModelContextProtocol.TestOAuthServer/Program.cs

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ public sealed class Program
1313
{
1414
private const int _port = 7029;
1515
private static readonly string _url = $"https://localhost:{_port}";
16+
private static readonly string _clientMetadataDocumentUrl = $"{_url}/client-metadata/cimd-client.json";
1617

1718
// Port 5000 is used by tests and port 7071 is used by the ProtectedMcpServer sample
1819
private static readonly string[] ValidResources = ["http://localhost:5000/", "http://localhost:7071/"];
@@ -44,6 +45,16 @@ public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactor
4445
public bool HasIssuedExpiredToken { get; set; }
4546
public bool HasRefreshedToken { get; set; }
4647

48+
/// <summary>
49+
/// Gets or sets a value indicating whether the authorization server
50+
/// advertises support for client ID metadata documents in its discovery
51+
/// document. This is used by tests to toggle CIMD support.
52+
/// </summary>
53+
/// <remarks>
54+
/// The default value is <c>true</c>.
55+
/// </remarks>
56+
public bool ClientIdMetadataDocumentSupported { get; set; } = true;
57+
4758
/// <summary>
4859
/// Entry point for the application.
4960
/// </summary>
@@ -102,6 +113,7 @@ public async Task RunServerAsync(string[]? args = null, CancellationToken cancel
102113
_clients[clientId] = new ClientInfo
103114
{
104115
ClientId = clientId,
116+
RequiresClientSecret = true,
105117
ClientSecret = clientSecret,
106118
RedirectUris = ["http://localhost:1179/callback"],
107119
};
@@ -111,10 +123,24 @@ public async Task RunServerAsync(string[]? args = null, CancellationToken cancel
111123
_clients["test-refresh-client"] = new ClientInfo
112124
{
113125
ClientId = "test-refresh-client",
126+
RequiresClientSecret = true,
114127
ClientSecret = "test-refresh-secret",
115128
RedirectUris = ["http://localhost:1179/callback"],
116129
};
117130

131+
// This client is pre-registered to support testing Client ID Metadata Documents (CIMD).
132+
// A non-test OAuth server implementation would fetch the metadata document from the client-specified
133+
// URL during authorization, but we just register the client here to keep the test implementation simple.
134+
// We also set 'RequiresClientSecret' to 'false' here because client secrets are disallowed when using CIMD.
135+
// See https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00#section-4.1
136+
_clients[_clientMetadataDocumentUrl] = new ClientInfo
137+
{
138+
ClientId = _clientMetadataDocumentUrl,
139+
140+
RequiresClientSecret = false,
141+
RedirectUris = ["http://localhost:1179/callback"],
142+
};
143+
118144
// The MCP spec tells the client to use /.well-known/oauth-authorization-server but AddJwtBearer looks for
119145
// /.well-known/openid-configuration by default. To make things easier, we support both with the same response
120146
// which seems to be common. Ex. https://github.com/keycloak/keycloak/pull/29628
@@ -144,7 +170,8 @@ public async Task RunServerAsync(string[]? args = null, CancellationToken cancel
144170
CodeChallengeMethodsSupported = ["S256"],
145171
GrantTypesSupported = ["authorization_code", "refresh_token"],
146172
IntrospectionEndpoint = $"{_url}/introspect",
147-
RegistrationEndpoint = $"{_url}/register"
173+
RegistrationEndpoint = $"{_url}/register",
174+
ClientIdMetadataDocumentSupported = ClientIdMetadataDocumentSupported,
148175
};
149176

150177
return Results.Ok(metadata);
@@ -468,6 +495,7 @@ public async Task RunServerAsync(string[]? args = null, CancellationToken cancel
468495
_clients[clientId] = new ClientInfo
469496
{
470497
ClientId = clientId,
498+
RequiresClientSecret = true,
471499
ClientSecret = clientSecret,
472500
RedirectUris = registrationRequest.RedirectUris,
473501
};
@@ -508,17 +536,17 @@ public async Task RunServerAsync(string[]? args = null, CancellationToken cancel
508536
var clientId = form["client_id"].ToString();
509537
var clientSecret = form["client_secret"].ToString();
510538

511-
if (string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(clientSecret))
539+
if (string.IsNullOrEmpty(clientId) || !_clients.TryGetValue(clientId, out var client))
512540
{
513541
return null;
514542
}
515543

516-
if (_clients.TryGetValue(clientId, out var client) && client.ClientSecret == clientSecret)
544+
if (client.RequiresClientSecret && client.ClientSecret != clientSecret)
517545
{
518-
return client;
546+
return null;
519547
}
520548

521-
return null;
549+
return client;
522550
}
523551

524552
/// <summary>

0 commit comments

Comments
 (0)