Skip to content

Commit bc4c361

Browse files
committed
MCP with CIMD Sample
1 parent fee536a commit bc4c361

144 files changed

Lines changed: 59504 additions & 0 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

IdentityServer/v7/CIMD/.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
keys/
2+
*.pem
3+
*.pfx
4+
*.p12
5+
6+
# Override the repo-root "tools/" ignore rule so that our Tools/ source directory is tracked
7+
!**/Tools/
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"servers": {
3+
"cimd-demo": {
4+
"type": "http",
5+
"url": "https://localhost:7241/"
6+
}
7+
}
8+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<Project Sdk="Microsoft.NET.Sdk.Web">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net10.0</TargetFramework>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<Nullable>enable</Nullable>
7+
</PropertyGroup>
8+
9+
<ItemGroup>
10+
<PackageReference Include="Duende.IdentityServer" Version="7.4.6" />
11+
<PackageReference Include="idunno.Security.Ssrf" Version="2.1.0-prerelease.ga732cc9dbc" />
12+
<PackageReference Include="Duende.IdentityModel" Version="8.0.0" />
13+
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="10.4.0" />
14+
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
15+
</ItemGroup>
16+
17+
</Project>
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Copyright (c) Duende Software. All rights reserved.
2+
// Licensed under the MIT License. See LICENSE in the project root for license information.
3+
4+
using System.Text.Json;
5+
using System.Text.Json.Serialization;
6+
using Duende.IdentityModel.Jwk;
7+
using Duende.IdentityServer;
8+
using Duende.IdentityServer.Models;
9+
10+
namespace CIMD.IdentityServer;
11+
12+
/// <summary>
13+
/// Creates the IdentityServer Client model from a CIMD document and optional JWKS
14+
/// </summary>
15+
public static class CimdClientBuilder
16+
{
17+
private static readonly JsonSerializerOptions JwkSerializerOptions = new()
18+
{
19+
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
20+
IgnoreReadOnlyFields = true,
21+
IgnoreReadOnlyProperties = true,
22+
};
23+
24+
public static Client Build(
25+
string clientId,
26+
CimdDocument document,
27+
JsonWebKeySet? keySet)
28+
{
29+
var scopes = document.Scope?.Split(' ').ToList() ?? [];
30+
var allowOfflineAccess = scopes.Contains("offline_access");
31+
scopes.Remove("offline_access");
32+
33+
var client = new Client
34+
{
35+
ClientId = clientId,
36+
ClientName = document.ClientName,
37+
LogoUri = document.LogoUri?.ToString(),
38+
ClientUri = document.ClientUri?.ToString(),
39+
RedirectUris = document.RedirectUris?.Select(u => u.ToString()).ToList() ?? [],
40+
PostLogoutRedirectUris = document.PostLogoutRedirectUris?.Select(u => u.ToString()).ToList() ?? [],
41+
AllowedGrantTypes = document.GrantTypes?.ToList() ?? GrantTypes.Code,
42+
RequireClientSecret = keySet is not null,
43+
AllowedScopes = scopes,
44+
AllowOfflineAccess = allowOfflineAccess,
45+
46+
// MCP clients discovered via CIMD are untrusted by default — require user
47+
// consent so that the end-user explicitly authorises the tools/scopes an
48+
// AI agent is requesting before access is granted.
49+
RequireConsent = true
50+
};
51+
52+
if (keySet is not null)
53+
{
54+
foreach (var key in keySet.Keys)
55+
{
56+
var jwk = JsonSerializer.Serialize(key, JwkSerializerOptions);
57+
client.ClientSecrets.Add(new Secret
58+
{
59+
Type = IdentityServerConstants.SecretTypes.JsonWebKey,
60+
Value = jwk
61+
});
62+
}
63+
}
64+
65+
return client;
66+
}
67+
}
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
// Copyright (c) Duende Software. All rights reserved.
2+
// Licensed under the MIT License. See LICENSE in the project root for license information.
3+
4+
using Duende.IdentityServer.Models;
5+
using Duende.IdentityServer.Stores;
6+
using Microsoft.Extensions.Caching.Hybrid;
7+
8+
namespace CIMD.IdentityServer;
9+
10+
/// <summary>
11+
/// Decorating <see cref="IClientStore"/> that adds CIMD (Client ID Metadata
12+
/// Document) support. If the client_id is a well-formed CIMD URI (HTTPS with
13+
/// a path), the document is fetched, validated, and mapped to a
14+
/// <see cref="Client"/>. Otherwise, the request is delegated to the inner
15+
/// store, allowing statically configured clients to coexist with CIMD clients.
16+
/// Uses <see cref="HybridCache"/> for caching with automatic expiration.
17+
/// </summary>
18+
/// <typeparam name="T">The inner <see cref="IClientStore"/> implementation to
19+
/// delegate to for non-CIMD client IDs. Resolved automatically by DI, following
20+
/// the same generic-constraint stacking pattern used by IdentityServer's own
21+
/// <c>CachingClientStore&lt;T&gt;</c> and <c>ValidatingClientStore&lt;T&gt;</c>.</typeparam>
22+
/// <remarks>
23+
/// <para><strong>Known limitation — document change detection:</strong>
24+
/// When a cached CIMD document expires and is re-fetched, this implementation
25+
/// does not compare the new document to the previously accepted one. If the
26+
/// document has changed (e.g., new redirect URIs, rotated keys, different
27+
/// grant types), the new version is accepted without review. A production
28+
/// implementation should consider persisting previously accepted documents
29+
/// and comparing on re-fetch so that policy can evaluate whether the changes
30+
/// are acceptable or represent a potential compromise.</para>
31+
/// <para>As of this writing, the CIMD draft itself has an open TODO in
32+
/// section 4.3 (Metadata Caching) regarding stale data considerations.</para>
33+
/// </remarks>
34+
public partial class CimdClientStore<T>(
35+
T innerStore,
36+
CimdDocumentFetcher fetcher,
37+
ICimdPolicy policy,
38+
HybridCache cache,
39+
ILogger<CimdClientStore<T>> logger) : IClientStore
40+
where T : IClientStore
41+
{
42+
private static readonly TimeSpan ResolutionTimeout = TimeSpan.FromSeconds(15);
43+
44+
public async Task<Client?> FindClientByIdAsync(string clientId)
45+
{
46+
// If the client_id isn't a valid CIMD URI, delegate to the inner store
47+
// (e.g., in-memory clients configured in Config.cs).
48+
if (!TryParseClientUri(clientId, out _))
49+
{
50+
return await innerStore.FindClientByIdAsync(clientId);
51+
}
52+
53+
using var cts = new CancellationTokenSource(ResolutionTimeout);
54+
try
55+
{
56+
return await cache.GetOrCreateAsync(
57+
$"cimd-client:{clientId}",
58+
async ct => await ResolveClientAsync(clientId, ct),
59+
cancellationToken: cts.Token);
60+
}
61+
catch (CimdResolutionException)
62+
{
63+
// Resolution failed — don't cache the failure
64+
return null;
65+
}
66+
catch (OperationCanceledException) when (cts.IsCancellationRequested)
67+
{
68+
Log.ResolutionTimedOut(logger, clientId);
69+
return null;
70+
}
71+
}
72+
73+
/// <summary>
74+
/// Performs the full CIMD resolution pipeline. Throws
75+
/// <see cref="CimdResolutionException"/> on any validation failure so that
76+
/// <see cref="HybridCache"/> does not cache the negative result.
77+
/// </summary>
78+
private async Task<Client> ResolveClientAsync(string clientId, CancellationToken ct)
79+
{
80+
// TryParseClientUri was already called by FindClientByIdAsync — this
81+
// is guaranteed to succeed, but we parse again to get the Uri value.
82+
if (!TryParseClientUri(clientId, out var clientUri))
83+
{
84+
throw new CimdResolutionException();
85+
}
86+
87+
// Domain allowlist policy
88+
var domainResult = await policy.CheckDomainAsync(clientUri, ct);
89+
if (!domainResult.IsAllowed)
90+
{
91+
Log.DomainDeniedByPolicy(logger, clientId, domainResult.Reason);
92+
throw new CimdResolutionException();
93+
}
94+
95+
// Fetch and deserialize the CIMD document
96+
var context = await fetcher.FetchAsync(clientUri, ct);
97+
if (context == null)
98+
{
99+
throw new CimdResolutionException();
100+
}
101+
102+
// Validate document contents
103+
if (!CimdDocumentValidator.ClientIdMatchesDocument(clientId, context.Document))
104+
{
105+
Log.ClientIdMismatch(logger, clientId);
106+
throw new CimdResolutionException();
107+
}
108+
109+
if (!CimdDocumentValidator.PassesAuthMethodChecks(context.Document, out var authMethodFailureReason))
110+
{
111+
Log.AuthMethodCheckFailed(logger, clientId, authMethodFailureReason);
112+
throw new CimdResolutionException();
113+
}
114+
115+
// Document-level policy check (has access to response headers via context)
116+
var documentResult = await policy.ValidateDocumentAsync(context, ct);
117+
if (!documentResult.IsAllowed)
118+
{
119+
Log.DocumentDeniedByPolicy(logger, clientId, documentResult.Reason);
120+
throw new CimdResolutionException();
121+
}
122+
123+
// Enforce scope policy: merge defaults and filter disallowed scopes
124+
var removedScopes = EnforceScopePolicy(context.Document, policy);
125+
if (removedScopes.Count > 0)
126+
{
127+
Log.ScopesRemovedByPolicy(logger, clientId, string.Join(", ", removedScopes));
128+
}
129+
130+
// Redirect URI validation (CIMD spec section 6.1)
131+
foreach (var redirectUri in context.Document.RedirectUris)
132+
{
133+
var redirectResult = await policy.ValidateRedirectUriAsync(redirectUri, context, ct);
134+
if (!redirectResult.IsAllowed)
135+
{
136+
Log.RedirectUriDeniedByPolicy(logger, clientId, redirectUri.ToString(), redirectResult.Reason);
137+
throw new CimdResolutionException();
138+
}
139+
}
140+
141+
// Resolve keys and build the IdentityServer client
142+
var keySet = await fetcher.ResolveJwksAsync(context, ct);
143+
var client = CimdClientBuilder.Build(clientId, context.Document, keySet);
144+
145+
Log.RegisteredCimdClient(logger, clientId);
146+
return client;
147+
}
148+
149+
/// <summary>
150+
/// Per spec section 3: client URI must be HTTPS, contain a path component,
151+
/// and MUST NOT contain single/double-dot path segments, a fragment, or
152+
/// a username or password.
153+
/// </summary>
154+
private static bool TryParseClientUri(string clientId, out Uri clientUri)
155+
{
156+
if (!Uri.TryCreate(clientId, UriKind.Absolute, out clientUri!) ||
157+
clientUri.Scheme != "https" ||
158+
string.IsNullOrEmpty(clientUri.AbsolutePath.TrimStart('/')) ||
159+
!string.IsNullOrEmpty(clientUri.Fragment) ||
160+
!string.IsNullOrEmpty(clientUri.UserInfo) ||
161+
clientUri.Segments.Any(s => s == "./" || s == "../"))
162+
{
163+
clientUri = null!;
164+
return false;
165+
}
166+
return true;
167+
}
168+
169+
/// <summary>
170+
/// Thrown when CIMD resolution fails, signaling that the result should
171+
/// not be cached by <see cref="HybridCache"/>.
172+
/// </summary>
173+
private sealed class CimdResolutionException : Exception;
174+
175+
/// <summary>
176+
/// Merges the policy's <see cref="ICimdPolicy.DefaultScopes"/> into the
177+
/// document and removes any scopes not present in the combined set of
178+
/// default + allowed scopes. Scope comparison is case-sensitive per
179+
/// RFC 6749 section 3.3.
180+
/// </summary>
181+
/// <returns>The list of scopes that were removed from the document.</returns>
182+
private static IReadOnlyList<string> EnforceScopePolicy(
183+
CimdDocument document, ICimdPolicy policy)
184+
{
185+
var requested = document.Scope?
186+
.Split(' ', StringSplitOptions.RemoveEmptyEntries) ?? [];
187+
188+
// Build the full set of permitted scopes (case-sensitive per RFC 6749 §3.3)
189+
var permitted = new HashSet<string>(
190+
policy.DefaultScopes.Concat(policy.AllowedScopes),
191+
StringComparer.Ordinal);
192+
193+
var removed = requested.Where(s => !permitted.Contains(s)).ToList();
194+
195+
var filtered = requested.Where(s => permitted.Contains(s));
196+
var final = filtered.Union(policy.DefaultScopes, StringComparer.Ordinal);
197+
198+
document.Scope = string.Join(' ', final);
199+
return removed;
200+
}
201+
202+
private static partial class Log
203+
{
204+
[LoggerMessage(LogLevel.Debug, "Successfully registered CIMD client '{ClientId}'")]
205+
public static partial void RegisteredCimdClient(ILogger logger, string clientId);
206+
207+
[LoggerMessage(LogLevel.Error, "CIMD client URI '{ClientId}' was denied by policy: {Reason}")]
208+
public static partial void DomainDeniedByPolicy(ILogger logger, string clientId, string? reason);
209+
210+
[LoggerMessage(LogLevel.Error, "CIMD document for '{ClientId}' was denied by policy: {Reason}")]
211+
public static partial void DocumentDeniedByPolicy(ILogger logger, string clientId, string? reason);
212+
213+
[LoggerMessage(LogLevel.Error, "CIMD document client_id does not match the request URL '{ClientId}'")]
214+
public static partial void ClientIdMismatch(ILogger logger, string clientId);
215+
216+
[LoggerMessage(LogLevel.Error, "CIMD document for '{ClientId}' failed auth method validation: {Reason}")]
217+
public static partial void AuthMethodCheckFailed(ILogger logger, string clientId, string reason);
218+
219+
[LoggerMessage(LogLevel.Error, "CIMD document for '{ClientId}' has redirect URI '{RedirectUri}' denied by policy: {Reason}")]
220+
public static partial void RedirectUriDeniedByPolicy(ILogger logger, string clientId, string redirectUri, string? reason);
221+
222+
[LoggerMessage(LogLevel.Error, "CIMD resolution for '{ClientId}' timed out")]
223+
public static partial void ResolutionTimedOut(ILogger logger, string clientId);
224+
225+
[LoggerMessage(LogLevel.Information, "CIMD client '{ClientId}' requested scopes not permitted by policy; removed: {RemovedScopes}")]
226+
public static partial void ScopesRemovedByPolicy(ILogger logger, string clientId, string removedScopes);
227+
}
228+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Copyright (c) Duende Software. All rights reserved.
2+
// Licensed under the MIT License. See LICENSE in the project root for license information.
3+
4+
using Duende.IdentityModel.Client;
5+
6+
namespace CIMD.IdentityServer;
7+
8+
/// <summary>
9+
/// Represents a Client ID Metadata Document (CIMD). Structurally identical to
10+
/// <see cref="DynamicClientRegistrationDocument"/> — CIMD reuses the same JSON
11+
/// schema as RFC 7591 Dynamic Client Registration, but the document is hosted
12+
/// at a URL that serves as the client_id rather than being submitted to a
13+
/// registration endpoint.
14+
/// </summary>
15+
public class CimdDocument : DynamicClientRegistrationDocument;

0 commit comments

Comments
 (0)