|
| 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<T></c> and <c>ValidatingClientStore<T></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 | +} |
0 commit comments