Skip to content
Open

CIMD #303

Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions IdentityServer/v7/CIMD/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
keys/
*.pem
*.pfx
*.p12

# Override the repo-root "tools/" ignore rule so that our Tools/ source directory is tracked
!**/Tools/
8 changes: 8 additions & 0 deletions IdentityServer/v7/CIMD/.vscode/mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"servers": {
"cimd-demo": {
"type": "http",
"url": "https://localhost:7241/"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Duende.IdentityServer" Version="7.4.6" />
<PackageReference Include="idunno.Security.Ssrf" Version="2.1.0-prerelease.ga732cc9dbc" />
<PackageReference Include="Duende.IdentityModel" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="10.4.0" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
</ItemGroup>

</Project>
67 changes: 67 additions & 0 deletions IdentityServer/v7/CIMD/CIMD.IdentityServer/CimdClientBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Copyright (c) Duende Software. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.

using System.Text.Json;
using System.Text.Json.Serialization;
using Duende.IdentityModel.Jwk;
using Duende.IdentityServer;
using Duende.IdentityServer.Models;

namespace CIMD.IdentityServer;

/// <summary>
/// Creates the IdentityServer Client model from a CIMD document and optional JWKS
/// </summary>
public static class CimdClientBuilder
{
private static readonly JsonSerializerOptions JwkSerializerOptions = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
IgnoreReadOnlyFields = true,
IgnoreReadOnlyProperties = true,
};

public static Client Build(
string clientId,
CimdDocument document,
JsonWebKeySet? keySet)
{
var scopes = document.Scope?.Split(' ').ToList() ?? [];
var allowOfflineAccess = scopes.Contains("offline_access");
scopes.Remove("offline_access");

var client = new Client
{
ClientId = clientId,
ClientName = document.ClientName,
LogoUri = document.LogoUri?.ToString(),
ClientUri = document.ClientUri?.ToString(),
RedirectUris = document.RedirectUris?.Select(u => u.ToString()).ToList() ?? [],
Comment thread
bhazen marked this conversation as resolved.
PostLogoutRedirectUris = document.PostLogoutRedirectUris?.Select(u => u.ToString()).ToList() ?? [],
AllowedGrantTypes = document.GrantTypes?.ToList() ?? GrantTypes.Code,
RequireClientSecret = keySet is not null,
AllowedScopes = scopes,
AllowOfflineAccess = allowOfflineAccess,

// MCP clients discovered via CIMD are untrusted by default — require user
// consent so that the end-user explicitly authorises the tools/scopes an
// AI agent is requesting before access is granted.
RequireConsent = true
};

if (keySet is not null)
{
foreach (var key in keySet.Keys)
{
var jwk = JsonSerializer.Serialize(key, JwkSerializerOptions);
client.ClientSecrets.Add(new Secret
{
Type = IdentityServerConstants.SecretTypes.JsonWebKey,
Value = jwk
});
}
}

return client;
}
}
228 changes: 228 additions & 0 deletions IdentityServer/v7/CIMD/CIMD.IdentityServer/CimdClientStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
// Copyright (c) Duende Software. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.

using Duende.IdentityServer.Models;
using Duende.IdentityServer.Stores;
using Microsoft.Extensions.Caching.Hybrid;

namespace CIMD.IdentityServer;

/// <summary>
/// Decorating <see cref="IClientStore"/> that adds CIMD (Client ID Metadata
/// Document) support. If the client_id is a well-formed CIMD URI (HTTPS with
/// a path), the document is fetched, validated, and mapped to a
/// <see cref="Client"/>. Otherwise, the request is delegated to the inner
/// store, allowing statically configured clients to coexist with CIMD clients.
/// Uses <see cref="HybridCache"/> for caching with automatic expiration.
/// </summary>
/// <typeparam name="T">The inner <see cref="IClientStore"/> implementation to
/// delegate to for non-CIMD client IDs. Resolved automatically by DI, following
/// the same generic-constraint stacking pattern used by IdentityServer's own
/// <c>CachingClientStore&lt;T&gt;</c> and <c>ValidatingClientStore&lt;T&gt;</c>.</typeparam>
/// <remarks>
/// <para><strong>Known limitation — document change detection:</strong>
/// When a cached CIMD document expires and is re-fetched, this implementation
/// does not compare the new document to the previously accepted one. If the
/// document has changed (e.g., new redirect URIs, rotated keys, different
/// grant types), the new version is accepted without review. A production
/// implementation should consider persisting previously accepted documents
/// and comparing on re-fetch so that policy can evaluate whether the changes
/// are acceptable or represent a potential compromise.</para>
/// <para>As of this writing, the CIMD draft itself has an open TODO in
/// section 4.3 (Metadata Caching) regarding stale data considerations.</para>
/// </remarks>
public partial class CimdClientStore<T>(
T innerStore,
CimdDocumentFetcher fetcher,
ICimdPolicy policy,
HybridCache cache,
ILogger<CimdClientStore<T>> logger) : IClientStore
where T : IClientStore
{
private static readonly TimeSpan ResolutionTimeout = TimeSpan.FromSeconds(15);

public async Task<Client?> FindClientByIdAsync(string clientId)
{
// If the client_id isn't a valid CIMD URI, delegate to the inner store
// (e.g., in-memory clients configured in Config.cs).
if (!TryParseClientUri(clientId, out _))
{
return await innerStore.FindClientByIdAsync(clientId);
}

using var cts = new CancellationTokenSource(ResolutionTimeout);
try
{
return await cache.GetOrCreateAsync(
$"cimd-client:{clientId}",
async ct => await ResolveClientAsync(clientId, ct),
cancellationToken: cts.Token);
}
catch (CimdResolutionException)
{
// Resolution failed — don't cache the failure
return null;
}
catch (OperationCanceledException) when (cts.IsCancellationRequested)
{
Log.ResolutionTimedOut(logger, clientId);
return null;
}
}

/// <summary>
/// Performs the full CIMD resolution pipeline. Throws
/// <see cref="CimdResolutionException"/> on any validation failure so that
/// <see cref="HybridCache"/> does not cache the negative result.
/// </summary>
private async Task<Client> ResolveClientAsync(string clientId, CancellationToken ct)
{
// TryParseClientUri was already called by FindClientByIdAsync — this
// is guaranteed to succeed, but we parse again to get the Uri value.
if (!TryParseClientUri(clientId, out var clientUri))
{
throw new CimdResolutionException();
}

// Domain allowlist policy
var domainResult = await policy.CheckDomainAsync(clientUri, ct);
if (!domainResult.IsAllowed)
{
Log.DomainDeniedByPolicy(logger, clientId, domainResult.Reason);
throw new CimdResolutionException();
}

// Fetch and deserialize the CIMD document
var context = await fetcher.FetchAsync(clientUri, ct);
if (context == null)
{
throw new CimdResolutionException();
}

// Validate document contents
if (!CimdDocumentValidator.ClientIdMatchesDocument(clientId, context.Document))
{
Log.ClientIdMismatch(logger, clientId);
throw new CimdResolutionException();
}

if (!CimdDocumentValidator.PassesAuthMethodChecks(context.Document, out var authMethodFailureReason))
{
Log.AuthMethodCheckFailed(logger, clientId, authMethodFailureReason);
throw new CimdResolutionException();
}

// Document-level policy check (has access to response headers via context)
var documentResult = await policy.ValidateDocumentAsync(context, ct);
if (!documentResult.IsAllowed)
{
Log.DocumentDeniedByPolicy(logger, clientId, documentResult.Reason);
throw new CimdResolutionException();
}

// Enforce scope policy: merge defaults and filter disallowed scopes
var removedScopes = EnforceScopePolicy(context.Document, policy);
if (removedScopes.Count > 0)
{
Log.ScopesRemovedByPolicy(logger, clientId, string.Join(", ", removedScopes));
}

// Redirect URI validation (CIMD spec section 6.1)
foreach (var redirectUri in context.Document.RedirectUris)
{
var redirectResult = await policy.ValidateRedirectUriAsync(redirectUri, context, ct);
if (!redirectResult.IsAllowed)
{
Log.RedirectUriDeniedByPolicy(logger, clientId, redirectUri.ToString(), redirectResult.Reason);
throw new CimdResolutionException();
}
}

// Resolve keys and build the IdentityServer client
var keySet = await fetcher.ResolveJwksAsync(context, ct);
var client = CimdClientBuilder.Build(clientId, context.Document, keySet);

Log.RegisteredCimdClient(logger, clientId);
return client;
}

/// <summary>
/// Per spec section 3: client URI must be HTTPS, contain a path component,
/// and MUST NOT contain single/double-dot path segments, a fragment, or
/// a username or password.
/// </summary>
private static bool TryParseClientUri(string clientId, out Uri clientUri)
{
if (!Uri.TryCreate(clientId, UriKind.Absolute, out clientUri!) ||
clientUri.Scheme != "https" ||
string.IsNullOrEmpty(clientUri.AbsolutePath.TrimStart('/')) ||
!string.IsNullOrEmpty(clientUri.Fragment) ||
!string.IsNullOrEmpty(clientUri.UserInfo) ||
clientUri.Segments.Any(s => s == "./" || s == "../"))
{
clientUri = null!;
return false;
}
return true;
}

/// <summary>
/// Thrown when CIMD resolution fails, signaling that the result should
/// not be cached by <see cref="HybridCache"/>.
/// </summary>
private sealed class CimdResolutionException : Exception;

/// <summary>
/// Merges the policy's <see cref="ICimdPolicy.DefaultScopes"/> into the
/// document and removes any scopes not present in the combined set of
/// default + allowed scopes. Scope comparison is case-sensitive per
/// RFC 6749 section 3.3.
/// </summary>
/// <returns>The list of scopes that were removed from the document.</returns>
private static IReadOnlyList<string> EnforceScopePolicy(
CimdDocument document, ICimdPolicy policy)
{
var requested = document.Scope?
.Split(' ', StringSplitOptions.RemoveEmptyEntries) ?? [];

// Build the full set of permitted scopes (case-sensitive per RFC 6749 §3.3)
var permitted = new HashSet<string>(
policy.DefaultScopes.Concat(policy.AllowedScopes),
StringComparer.Ordinal);

var removed = requested.Where(s => !permitted.Contains(s)).ToList();

var filtered = requested.Where(s => permitted.Contains(s));
var final = filtered.Union(policy.DefaultScopes, StringComparer.Ordinal);

document.Scope = string.Join(' ', final);
return removed;
}

private static partial class Log
{
[LoggerMessage(LogLevel.Debug, "Successfully registered CIMD client '{ClientId}'")]
public static partial void RegisteredCimdClient(ILogger logger, string clientId);

[LoggerMessage(LogLevel.Error, "CIMD client URI '{ClientId}' was denied by policy: {Reason}")]
public static partial void DomainDeniedByPolicy(ILogger logger, string clientId, string? reason);

[LoggerMessage(LogLevel.Error, "CIMD document for '{ClientId}' was denied by policy: {Reason}")]
public static partial void DocumentDeniedByPolicy(ILogger logger, string clientId, string? reason);

[LoggerMessage(LogLevel.Error, "CIMD document client_id does not match the request URL '{ClientId}'")]
public static partial void ClientIdMismatch(ILogger logger, string clientId);

[LoggerMessage(LogLevel.Error, "CIMD document for '{ClientId}' failed auth method validation: {Reason}")]
public static partial void AuthMethodCheckFailed(ILogger logger, string clientId, string reason);

[LoggerMessage(LogLevel.Error, "CIMD document for '{ClientId}' has redirect URI '{RedirectUri}' denied by policy: {Reason}")]
public static partial void RedirectUriDeniedByPolicy(ILogger logger, string clientId, string redirectUri, string? reason);

[LoggerMessage(LogLevel.Error, "CIMD resolution for '{ClientId}' timed out")]
public static partial void ResolutionTimedOut(ILogger logger, string clientId);

[LoggerMessage(LogLevel.Information, "CIMD client '{ClientId}' requested scopes not permitted by policy; removed: {RemovedScopes}")]
public static partial void ScopesRemovedByPolicy(ILogger logger, string clientId, string removedScopes);
}
}
15 changes: 15 additions & 0 deletions IdentityServer/v7/CIMD/CIMD.IdentityServer/CimdDocument.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright (c) Duende Software. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.

using Duende.IdentityModel.Client;

namespace CIMD.IdentityServer;

/// <summary>
/// Represents a Client ID Metadata Document (CIMD). Structurally identical to
/// <see cref="DynamicClientRegistrationDocument"/> — CIMD reuses the same JSON
/// schema as RFC 7591 Dynamic Client Registration, but the document is hosted
/// at a URL that serves as the client_id rather than being submitted to a
/// registration endpoint.
/// </summary>
public class CimdDocument : DynamicClientRegistrationDocument;
Loading
Loading