diff --git a/OpenIddict.slnx b/OpenIddict.slnx
index 6dbe5ce6..69b2f560 100644
--- a/OpenIddict.slnx
+++ b/OpenIddict.slnx
@@ -73,6 +73,7 @@
+
diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.CimdServer/AuthorizationController.cs b/sandbox/OpenIddict.Sandbox.AspNetCore.CimdServer/AuthorizationController.cs
index 62ccfa0f..345662e6 100644
--- a/sandbox/OpenIddict.Sandbox.AspNetCore.CimdServer/AuthorizationController.cs
+++ b/sandbox/OpenIddict.Sandbox.AspNetCore.CimdServer/AuthorizationController.cs
@@ -66,9 +66,6 @@ public class AuthorizationController : Controller
var userEntity = await _userManager.GetUserAsync(result.Principal) ??
throw new InvalidOperationException("The user details cannot be retrieved.");
- var application = await _applicationManager.FindByClientIdAsync(request.ClientId!) ??
- throw new InvalidOperationException("Details concerning the calling client application cannot be found.");
-
// Auto-approve consent for this demonstrator.
var identity = new ClaimsIdentity(
authenticationType: TokenValidationParameters.DefaultAuthenticationType,
@@ -82,14 +79,21 @@ public class AuthorizationController : Controller
identity.SetScopes(request.GetScopes());
identity.SetResources(await _scopeManager.ListResourcesAsync(identity.GetScopes()).ToListAsync());
- var authorization = await _authorizationManager.CreateAsync(
- identity: identity,
- subject: await _userManager.GetUserIdAsync(userEntity),
- client: (await _applicationManager.GetIdAsync(application))!,
- type: AuthorizationTypes.Permanent,
- scopes: identity.GetScopes());
+ // For CIMD clients (URL-based client_id with no pre-registration), skip
+ // the application lookup and authorization entry creation.
+ var application = await _applicationManager.FindByClientIdAsync(request.ClientId!);
+ if (application is not null)
+ {
+ var authorization = await _authorizationManager.CreateAsync(
+ identity: identity,
+ subject: await _userManager.GetUserIdAsync(userEntity),
+ client: (await _applicationManager.GetIdAsync(application))!,
+ type: AuthorizationTypes.Permanent,
+ scopes: identity.GetScopes());
+
+ identity.SetAuthorizationId(await _authorizationManager.GetIdAsync(authorization));
+ }
- identity.SetAuthorizationId(await _authorizationManager.GetIdAsync(authorization));
identity.SetDestinations(GetDestinations);
return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.CimdServer/OpenIddict.Sandbox.AspNetCore.CimdServer.csproj b/sandbox/OpenIddict.Sandbox.AspNetCore.CimdServer/OpenIddict.Sandbox.AspNetCore.CimdServer.csproj
index c330ebf8..6f7707a6 100644
--- a/sandbox/OpenIddict.Sandbox.AspNetCore.CimdServer/OpenIddict.Sandbox.AspNetCore.CimdServer.csproj
+++ b/sandbox/OpenIddict.Sandbox.AspNetCore.CimdServer/OpenIddict.Sandbox.AspNetCore.CimdServer.csproj
@@ -9,6 +9,7 @@
+
diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.CimdServer/Program.cs b/sandbox/OpenIddict.Sandbox.AspNetCore.CimdServer/Program.cs
index 88320d20..e0cc1dde 100644
--- a/sandbox/OpenIddict.Sandbox.AspNetCore.CimdServer/Program.cs
+++ b/sandbox/OpenIddict.Sandbox.AspNetCore.CimdServer/Program.cs
@@ -38,12 +38,17 @@ builder.Services.AddOpenIddict()
options.RegisterScopes("openid", "profile", "email");
+ // Enable Client ID Metadata Document (CIMD) support.
+ options.EnableClientIdMetadataDocumentSupport();
+
options.AddDevelopmentEncryptionCertificate()
.AddDevelopmentSigningCertificate();
options.UseAspNetCore()
.EnableAuthorizationEndpointPassthrough()
.EnableTokenEndpointPassthrough();
+
+ options.UseSystemNetHttp();
})
.AddValidation(options =>
{
@@ -61,4 +66,17 @@ app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
+// Serve a CIMD metadata document for testing.
+// The client_id URL is: https://localhost:7295/clients/cimd-test
+app.MapGet("/clients/cimd-test", () => Results.Json(new
+{
+ client_id = "https://localhost:7295/clients/cimd-test",
+ client_name = "CIMD Test Client",
+ redirect_uris = new[] { "http://localhost/callback" },
+ grant_types = new[] { "authorization_code" },
+ response_types = new[] { "code" },
+ token_endpoint_auth_method = "none"
+}));
+
+Console.WriteLine("CIMD Server starting on https://localhost:7295");
app.Run();
diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.CimdServer/appsettings.json b/sandbox/OpenIddict.Sandbox.AspNetCore.CimdServer/appsettings.json
new file mode 100644
index 00000000..a1ff2104
--- /dev/null
+++ b/sandbox/OpenIddict.Sandbox.AspNetCore.CimdServer/appsettings.json
@@ -0,0 +1,10 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft": "Warning",
+ "Microsoft.Hosting.Lifetime": "Information",
+ "OpenIddict": "Debug"
+ }
+ }
+}
diff --git a/src/OpenIddict.Abstractions/OpenIddictConstants.cs b/src/OpenIddict.Abstractions/OpenIddictConstants.cs
index d1abe08d..19bfb74c 100644
--- a/src/OpenIddict.Abstractions/OpenIddictConstants.cs
+++ b/src/OpenIddict.Abstractions/OpenIddictConstants.cs
@@ -286,6 +286,7 @@ public static class OpenIddictConstants
public const string AcrValuesSupported = "acr_values_supported";
public const string AuthorizationEndpoint = "authorization_endpoint";
public const string AuthorizationResponseIssParameterSupported = "authorization_response_iss_parameter_supported";
+ public const string ClientIdMetadataDocumentSupported = "client_id_metadata_document_supported";
public const string ClaimsLocalesSupported = "claims_locales_supported";
public const string ClaimsParameterSupported = "claims_parameter_supported";
public const string ClaimsSupported = "claims_supported";
diff --git a/src/OpenIddict.Server.SystemNetHttp/OpenIddict.Server.SystemNetHttp.csproj b/src/OpenIddict.Server.SystemNetHttp/OpenIddict.Server.SystemNetHttp.csproj
new file mode 100644
index 00000000..f8a65bf6
--- /dev/null
+++ b/src/OpenIddict.Server.SystemNetHttp/OpenIddict.Server.SystemNetHttp.csproj
@@ -0,0 +1,45 @@
+
+
+
+
+ $(NetFrameworkTargetFrameworks);
+ $(NetCoreTargetFrameworks);
+ $(NetStandardTargetFrameworks)
+
+
+
+
+ System.Net.Http integration package for the OpenIddict server services.
+ $(PackageTags);http;httpclient
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OpenIddict.Server.SystemNetHttp/OpenIddictServerSystemNetHttpBuilder.cs b/src/OpenIddict.Server.SystemNetHttp/OpenIddictServerSystemNetHttpBuilder.cs
new file mode 100644
index 00000000..6f919110
--- /dev/null
+++ b/src/OpenIddict.Server.SystemNetHttp/OpenIddictServerSystemNetHttpBuilder.cs
@@ -0,0 +1,208 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
+ * See https://github.com/openiddict/openiddict-core for more information concerning
+ * the license and the contributors participating to this project.
+ */
+
+using System.ComponentModel;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Net.Mail;
+using System.Reflection;
+using OpenIddict.Server.SystemNetHttp;
+using Polly;
+
+namespace Microsoft.Extensions.DependencyInjection;
+
+///
+/// Exposes the necessary methods required to configure the OpenIddict server/System.Net.Http integration.
+///
+public sealed class OpenIddictServerSystemNetHttpBuilder
+{
+ ///
+ /// Initializes a new instance of .
+ ///
+ /// The services collection.
+ public OpenIddictServerSystemNetHttpBuilder(IServiceCollection services)
+ => Services = services ?? throw new ArgumentNullException(nameof(services));
+
+ ///
+ /// Gets the services collection.
+ ///
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ public IServiceCollection Services { get; }
+
+ ///
+ /// Amends the default OpenIddict server/System.Net.Http configuration.
+ ///
+ /// The delegate used to configure the OpenIddict options.
+ /// This extension can be safely called multiple times.
+ /// The instance.
+ public OpenIddictServerSystemNetHttpBuilder Configure(Action configuration)
+ {
+ ArgumentNullException.ThrowIfNull(configuration);
+
+ Services.Configure(configuration);
+
+ return this;
+ }
+
+ ///
+ /// Configures the used by the OpenIddict server/System.Net.Http integration.
+ ///
+ /// The delegate used to configure the .
+ /// The instance.
+ [EditorBrowsable(EditorBrowsableState.Advanced)]
+ public OpenIddictServerSystemNetHttpBuilder ConfigureHttpClient(Action configuration)
+ {
+ ArgumentNullException.ThrowIfNull(configuration);
+
+ return Configure(options => options.HttpClientActions.Add(configuration));
+ }
+
+ ///
+ /// Configures the used by the OpenIddict server/System.Net.Http integration.
+ ///
+ /// The delegate used to configure the .
+ /// The instance.
+ [EditorBrowsable(EditorBrowsableState.Advanced)]
+ public OpenIddictServerSystemNetHttpBuilder ConfigureHttpClientHandler(Action configuration)
+ {
+ ArgumentNullException.ThrowIfNull(configuration);
+
+ return Configure(options => options.HttpClientHandlerActions.Add(configuration));
+ }
+
+ ///
+ /// Sets the contact address used in the "From" header that is attached
+ /// to the HTTP requests sent by the OpenIddict server services.
+ ///
+ /// The mail address.
+ /// The instance.
+ public OpenIddictServerSystemNetHttpBuilder SetContactAddress(MailAddress address)
+ {
+ ArgumentNullException.ThrowIfNull(address);
+
+ return Configure(options => options.ContactAddress = address);
+ }
+
+ ///
+ /// Sets the contact address used in the "From" header that is attached
+ /// to the HTTP requests sent by the OpenIddict server services.
+ ///
+ /// The mail address.
+ /// The instance.
+ public OpenIddictServerSystemNetHttpBuilder SetContactAddress(string address)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(address);
+
+ return SetContactAddress(new MailAddress(address));
+ }
+
+ ///
+ /// Replaces the default HTTP error policy used by the OpenIddict server services.
+ ///
+ /// The HTTP Polly error policy.
+ /// The instance.
+ public OpenIddictServerSystemNetHttpBuilder SetHttpErrorPolicy(IAsyncPolicy policy)
+ {
+ ArgumentNullException.ThrowIfNull(policy);
+
+ return Configure(options => options.HttpErrorPolicy = policy);
+ }
+
+#if SUPPORTS_HTTP_CLIENT_RESILIENCE
+ ///
+ /// Replaces the default HTTP resilience pipeline used by the OpenIddict server services.
+ ///
+ ///
+ /// The delegate used to configure the .
+ ///
+ ///
+ /// Note: this option has no effect when an HTTP error policy was explicitly configured
+ /// using .
+ ///
+ /// The instance.
+ public OpenIddictServerSystemNetHttpBuilder SetHttpResiliencePipeline(
+ Action> configuration)
+ {
+ ArgumentNullException.ThrowIfNull(configuration);
+
+ var builder = new ResiliencePipelineBuilder();
+ configuration(builder);
+
+ return SetHttpResiliencePipeline(builder.Build());
+ }
+
+ ///
+ /// Replaces the default HTTP resilience pipeline used by the OpenIddict server services.
+ ///
+ /// The HTTP resilience pipeline.
+ ///
+ /// Note: this option has no effect when an HTTP error policy was explicitly configured
+ /// using .
+ ///
+ /// The instance.
+ public OpenIddictServerSystemNetHttpBuilder SetHttpResiliencePipeline(ResiliencePipeline pipeline)
+ {
+ ArgumentNullException.ThrowIfNull(pipeline);
+
+ return Configure(options => options.HttpResiliencePipeline = pipeline);
+ }
+#endif
+
+ ///
+ /// Sets the product information used in the "User-Agent" header that is attached
+ /// to the HTTP requests sent by the OpenIddict server services.
+ ///
+ /// The product information.
+ /// The instance.
+ public OpenIddictServerSystemNetHttpBuilder SetProductInformation(ProductInfoHeaderValue information)
+ {
+ ArgumentNullException.ThrowIfNull(information);
+
+ return Configure(options => options.ProductInformation = information);
+ }
+
+ ///
+ /// Sets the product information used in the "User-Agent" header that is attached
+ /// to the HTTP requests sent by the OpenIddict server services.
+ ///
+ /// The product name.
+ /// The product version.
+ /// The instance.
+ public OpenIddictServerSystemNetHttpBuilder SetProductInformation(string name, string? version)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(name);
+
+ return SetProductInformation(new ProductInfoHeaderValue(name, version));
+ }
+
+ ///
+ /// Sets the product information used in the user agent header that is attached
+ /// to the HTTP requests sent by the OpenIddict server services based
+ /// on the identity of the specified .NET assembly (name and version).
+ ///
+ /// The assembly from which the product information is created.
+ /// The instance.
+ public OpenIddictServerSystemNetHttpBuilder SetProductInformation(Assembly assembly)
+ {
+ ArgumentNullException.ThrowIfNull(assembly);
+
+ return SetProductInformation(new ProductInfoHeaderValue(
+ productName: assembly.GetName().Name!,
+ productVersion: assembly.GetName().Version!.ToString()));
+ }
+
+ ///
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ public override bool Equals(object? obj) => base.Equals(obj);
+
+ ///
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ public override int GetHashCode() => base.GetHashCode();
+
+ ///
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ public override string? ToString() => base.ToString();
+}
diff --git a/src/OpenIddict.Server.SystemNetHttp/OpenIddictServerSystemNetHttpConfiguration.cs b/src/OpenIddict.Server.SystemNetHttp/OpenIddictServerSystemNetHttpConfiguration.cs
new file mode 100644
index 00000000..d04fe739
--- /dev/null
+++ b/src/OpenIddict.Server.SystemNetHttp/OpenIddictServerSystemNetHttpConfiguration.cs
@@ -0,0 +1,157 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
+ * See https://github.com/openiddict/openiddict-core for more information concerning
+ * the license and the contributors participating to this project.
+ */
+
+using System.ComponentModel;
+using System.Net;
+using System.Net.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Http;
+using Microsoft.Extensions.Options;
+using Polly;
+
+#if SUPPORTS_HTTP_CLIENT_RESILIENCE
+using Microsoft.Extensions.Http.Resilience;
+#endif
+
+namespace OpenIddict.Server.SystemNetHttp;
+
+///
+/// Contains the methods required to ensure that the OpenIddict server/System.Net.Http integration configuration is valid.
+///
+[EditorBrowsable(EditorBrowsableState.Advanced)]
+public sealed class OpenIddictServerSystemNetHttpConfiguration : IConfigureOptions,
+ IConfigureNamedOptions,
+ IPostConfigureOptions
+{
+ private readonly IServiceProvider _provider;
+
+ ///
+ /// Creates a new instance of the class.
+ ///
+ /// The service provider.
+ public OpenIddictServerSystemNetHttpConfiguration(IServiceProvider provider)
+ => _provider = provider ?? throw new ArgumentNullException(nameof(provider));
+
+ ///
+ public void Configure(OpenIddictServerOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ // Register the built-in event handlers used by the OpenIddict System.Net.Http server components.
+ options.Handlers.AddRange(OpenIddictServerSystemNetHttpHandlers.DefaultHandlers);
+ }
+
+ ///
+ public void Configure(HttpClientFactoryOptions options) => Configure(Options.DefaultName, options);
+
+ ///
+ public void Configure(string? name, HttpClientFactoryOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var assembly = typeof(OpenIddictServerSystemNetHttpOptions).Assembly.GetName();
+
+ // Only amend the HTTP client factory options if the instance is managed by OpenIddict.
+ if (string.IsNullOrEmpty(name) || !name.StartsWith(assembly.Name!, StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ var settings = _provider.GetRequiredService>().CurrentValue;
+
+ options.HttpClientActions.Add(static client =>
+ {
+ // By default, HttpClient uses a default timeout of 100 seconds and allows payloads of up to 2GB.
+ // To help reduce the effects of malicious responses (e.g responses returned at a very slow pace
+ // or containing an infinite amount of data), the default values are amended to use lower values.
+ client.MaxResponseContentBufferSize = 10 * 1024 * 1024;
+ client.Timeout = TimeSpan.FromMinutes(1);
+ });
+
+ // Register the user-defined HTTP client actions.
+ foreach (var action in settings.HttpClientActions)
+ {
+ options.HttpClientActions.Add(action);
+ }
+
+ options.HttpMessageHandlerBuilderActions.Add(builder =>
+ {
+ var options = builder.Services.GetRequiredService>();
+
+ // If applicable, add the handler responsible for replaying failed HTTP requests.
+ //
+ // Note: on .NET 8.0 and higher, the HTTP error policy is always set
+ // to null by default and an HTTP resilience pipeline is used instead.
+ if (options.CurrentValue.HttpErrorPolicy is IAsyncPolicy policy)
+ {
+ builder.AdditionalHandlers.Add(new PolicyHttpMessageHandler(policy));
+ }
+
+#if SUPPORTS_HTTP_CLIENT_RESILIENCE
+ else if (options.CurrentValue.HttpResiliencePipeline is ResiliencePipeline pipeline)
+ {
+#pragma warning disable EXTEXP0001
+ builder.AdditionalHandlers.Add(new ResilienceHandler(pipeline));
+#pragma warning restore EXTEXP0001
+ }
+#endif
+ if (builder.PrimaryHandler is not HttpClientHandler handler)
+ {
+ throw new InvalidOperationException(SR.FormatID0373(typeof(HttpClientHandler).FullName));
+ }
+ });
+
+ // Register the user-defined HTTP client handler actions.
+ foreach (var action in settings.HttpClientHandlerActions)
+ {
+ options.HttpMessageHandlerBuilderActions.Add(builder => action(
+ builder.PrimaryHandler as HttpClientHandler ??
+ throw new InvalidOperationException(SR.FormatID0373(typeof(HttpClientHandler).FullName))));
+ }
+ }
+
+ ///
+ public void PostConfigure(string? name, HttpClientFactoryOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var assembly = typeof(OpenIddictServerSystemNetHttpOptions).Assembly.GetName();
+
+ // Only amend the HTTP client factory options if the instance is managed by OpenIddict.
+ if (string.IsNullOrEmpty(name) || !name.StartsWith(assembly.Name!, StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ options.HttpMessageHandlerBuilderActions.Insert(0, static builder =>
+ {
+ // Note: Microsoft.Extensions.Http 9.0+ no longer uses HttpClientHandler as the default instance
+ // for PrimaryHandler on platforms that support SocketsHttpHandler. Since OpenIddict requires an
+ // HttpClientHandler instance, it is manually reassigned here if it's not an HttpClientHandler.
+ if (builder.PrimaryHandler is not HttpClientHandler)
+ {
+ builder.PrimaryHandler = new HttpClientHandler();
+ }
+ });
+
+ options.HttpMessageHandlerBuilderActions.Add(static builder =>
+ {
+ if (builder.PrimaryHandler is not HttpClientHandler handler)
+ {
+ throw new InvalidOperationException(SR.FormatID0373(typeof(HttpClientHandler).FullName));
+ }
+
+ // Disable automatic content decompression for security reasons (BREACH attacks).
+ if (handler.SupportsAutomaticDecompression)
+ {
+ handler.AutomaticDecompression = DecompressionMethods.None;
+ }
+
+ // Disable cookies support for security reasons.
+ handler.UseCookies = false;
+ });
+ }
+}
diff --git a/src/OpenIddict.Server.SystemNetHttp/OpenIddictServerSystemNetHttpConstants.cs b/src/OpenIddict.Server.SystemNetHttp/OpenIddictServerSystemNetHttpConstants.cs
new file mode 100644
index 00000000..34155960
--- /dev/null
+++ b/src/OpenIddict.Server.SystemNetHttp/OpenIddictServerSystemNetHttpConstants.cs
@@ -0,0 +1,37 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
+ * See https://github.com/openiddict/openiddict-core for more information concerning
+ * the license and the contributors participating to this project.
+ */
+
+namespace OpenIddict.Server.SystemNetHttp;
+
+///
+/// Exposes common constants used by the OpenIddict server/System.Net.Http integration.
+///
+public static class OpenIddictServerSystemNetHttpConstants
+{
+ public static class Charsets
+ {
+ public const string Utf8 = "utf-8";
+ }
+
+ public static class ContentEncodings
+ {
+ public const string Brotli = "br";
+ public const string Deflate = "deflate";
+ public const string Gzip = "gzip";
+ public const string Identity = "identity";
+ }
+
+ public static class MediaTypes
+ {
+ public const string Json = "application/json";
+ }
+
+ public static class Properties
+ {
+ public const string ClientIdMetadataDocument = ".ClientIdMetadataDocument";
+ public const string ClientIdMetadataDocumentFetchRequired = ".ClientIdMetadataDocumentFetchRequired";
+ }
+}
diff --git a/src/OpenIddict.Server.SystemNetHttp/OpenIddictServerSystemNetHttpExtensions.cs b/src/OpenIddict.Server.SystemNetHttp/OpenIddictServerSystemNetHttpExtensions.cs
new file mode 100644
index 00000000..bfdc7eb6
--- /dev/null
+++ b/src/OpenIddict.Server.SystemNetHttp/OpenIddictServerSystemNetHttpExtensions.cs
@@ -0,0 +1,69 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
+ * See https://github.com/openiddict/openiddict-core for more information concerning
+ * the license and the contributors participating to this project.
+ */
+
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Http;
+using Microsoft.Extensions.Options;
+using OpenIddict.Server;
+using OpenIddict.Server.SystemNetHttp;
+
+namespace Microsoft.Extensions.DependencyInjection;
+
+///
+/// Exposes extensions allowing to register the OpenIddict server/System.Net.Http integration services.
+///
+public static class OpenIddictServerSystemNetHttpExtensions
+{
+ ///
+ /// Registers the OpenIddict server/System.Net.Http integration services in the DI container.
+ ///
+ /// The services builder used by OpenIddict to register new services.
+ /// This extension can be safely called multiple times.
+ /// The instance.
+ public static OpenIddictServerSystemNetHttpBuilder UseSystemNetHttp(this OpenIddictServerBuilder builder)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ builder.Services.AddHttpClient();
+
+ // Register the built-in event handlers used by the OpenIddict System.Net.Http components.
+ // Note: the order used here is not important, as the actual order is set in the options.
+ builder.Services.TryAdd(OpenIddictServerSystemNetHttpHandlers.DefaultHandlers.Select(descriptor => descriptor.ServiceDescriptor));
+
+ // Register the built-in filters used by the default OpenIddict System.Net.Http event handlers.
+ builder.Services.TryAddSingleton();
+
+ // Note: TryAddEnumerable() is used here to ensure the initializers are registered only once.
+ builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<
+ IConfigureOptions, OpenIddictServerSystemNetHttpConfiguration>());
+
+ builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<
+ IConfigureOptions, OpenIddictServerSystemNetHttpConfiguration>());
+
+ builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<
+ IPostConfigureOptions, OpenIddictServerSystemNetHttpConfiguration>());
+
+ return new OpenIddictServerSystemNetHttpBuilder(builder.Services);
+ }
+
+ ///
+ /// Registers the OpenIddict server/System.Net.Http integration services in the DI container.
+ ///
+ /// The services builder used by OpenIddict to register new services.
+ /// The configuration delegate used to configure the server services.
+ /// This extension can be safely called multiple times.
+ /// The instance.
+ public static OpenIddictServerBuilder UseSystemNetHttp(
+ this OpenIddictServerBuilder builder, Action configuration)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(configuration);
+
+ configuration(builder.UseSystemNetHttp());
+
+ return builder;
+ }
+}
diff --git a/src/OpenIddict.Server.SystemNetHttp/OpenIddictServerSystemNetHttpHandlerFilters.cs b/src/OpenIddict.Server.SystemNetHttp/OpenIddictServerSystemNetHttpHandlerFilters.cs
new file mode 100644
index 00000000..2d4b5642
--- /dev/null
+++ b/src/OpenIddict.Server.SystemNetHttp/OpenIddictServerSystemNetHttpHandlerFilters.cs
@@ -0,0 +1,28 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
+ * See https://github.com/openiddict/openiddict-core for more information concerning
+ * the license and the contributors participating to this project.
+ */
+
+using System.ComponentModel;
+
+namespace OpenIddict.Server.SystemNetHttp;
+
+[EditorBrowsable(EditorBrowsableState.Advanced)]
+public static class OpenIddictServerSystemNetHttpHandlerFilters
+{
+ ///
+ /// Represents a filter that excludes the associated handlers
+ /// if CIMD support was not enabled in the server options.
+ ///
+ public sealed class RequireClientIdMetadataDocumentSupportEnabled : IOpenIddictServerHandlerFilter
+ {
+ ///
+ public ValueTask IsActiveAsync(BaseContext context)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+
+ return new(context.Options.EnableClientIdMetadataDocumentSupport);
+ }
+ }
+}
diff --git a/src/OpenIddict.Server.SystemNetHttp/OpenIddictServerSystemNetHttpHandlers.Authentication.cs b/src/OpenIddict.Server.SystemNetHttp/OpenIddictServerSystemNetHttpHandlers.Authentication.cs
new file mode 100644
index 00000000..7bca8beb
--- /dev/null
+++ b/src/OpenIddict.Server.SystemNetHttp/OpenIddictServerSystemNetHttpHandlers.Authentication.cs
@@ -0,0 +1,228 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
+ * See https://github.com/openiddict/openiddict-core for more information concerning
+ * the license and the contributors participating to this project.
+ */
+
+using System.Collections.Immutable;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Text.Json;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace OpenIddict.Server.SystemNetHttp;
+
+public static partial class OpenIddictServerSystemNetHttpHandlers
+{
+ public static class Authentication
+ {
+ public static ImmutableArray DefaultHandlers { get; } =
+ [
+ FetchClientIdMetadataDocument.Descriptor
+ ];
+
+ ///
+ /// Contains the logic responsible for fetching the CIMD metadata document
+ /// when the client_id is an HTTPS URL and no pre-registered client was found.
+ ///
+ public sealed class FetchClientIdMetadataDocument : IOpenIddictServerHandler
+ {
+ private readonly IHttpClientFactory _factory;
+ private readonly IOptionsMonitor _serverOptions;
+ private readonly IOptionsMonitor _httpOptions;
+
+ public FetchClientIdMetadataDocument(
+ IHttpClientFactory factory,
+ IOptionsMonitor serverOptions,
+ IOptionsMonitor httpOptions)
+ {
+ _factory = factory ?? throw new ArgumentNullException(nameof(factory));
+ _serverOptions = serverOptions ?? throw new ArgumentNullException(nameof(serverOptions));
+ _httpOptions = httpOptions ?? throw new ArgumentNullException(nameof(httpOptions));
+ }
+
+ ///
+ /// Gets the default descriptor definition assigned to this handler.
+ ///
+ public static OpenIddictServerHandlerDescriptor Descriptor { get; }
+ = OpenIddictServerHandlerDescriptor.CreateBuilder()
+ .AddFilter()
+ .UseScopedHandler()
+ // Run after ValidateAuthentication and before RestorePushedAuthorizationRequestParameters.
+ .SetOrder(OpenIddictServerHandlers.Authentication.ValidateAuthentication.Descriptor.Order + 500)
+ .SetType(OpenIddictServerHandlerType.BuiltIn)
+ .Build();
+
+ ///
+ public async ValueTask HandleAsync(ValidateAuthorizationRequestContext context)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+
+ // Only proceed if the transaction was flagged as requiring CIMD fetch.
+ // This flag is set by the modified ValidateClientId handler (Phase 3) when
+ // FindByClientIdAsync() returns null and the client_id is a valid CIMD URL.
+ if (!context.Transaction.Properties.TryGetValue(
+ OpenIddictServerSystemNetHttpConstants.Properties.ClientIdMetadataDocumentFetchRequired, out var value) ||
+ value is not true)
+ {
+ return;
+ }
+
+ var clientId = context.Transaction.Request?.ClientId;
+ if (string.IsNullOrEmpty(clientId))
+ {
+ return;
+ }
+
+ if (!Uri.TryCreate(clientId, UriKind.Absolute, out var clientUri) ||
+ !string.Equals(clientUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
+ {
+ context.Reject(
+ error: Errors.InvalidClient,
+ description: "The specified client_id is not a valid HTTPS URL.",
+ uri: null);
+
+ return;
+ }
+
+ var options = _serverOptions.CurrentValue;
+
+ var assembly = typeof(OpenIddictServerSystemNetHttpOptions).Assembly.GetName();
+ var client = _factory.CreateClient(assembly.Name!);
+
+ // Apply size limit and timeout from server options.
+ client.Timeout = options.ClientIdMetadataDocumentFetchTimeout;
+
+ using var request = new HttpRequestMessage(HttpMethod.Get, clientUri);
+ request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+
+ // Attach User-Agent header if configured.
+ var httpOptions = _httpOptions.CurrentValue;
+ if (httpOptions.ProductInformation is not null)
+ {
+ request.Headers.UserAgent.Add(httpOptions.ProductInformation);
+ }
+
+ using var response = await client.SendAsync(request, context.CancellationToken);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ context.Logger.LogWarning(
+ "The CIMD metadata document fetch for client_id '{ClientId}' failed with status code {StatusCode}.",
+ clientId, response.StatusCode);
+
+ context.Reject(
+ error: Errors.InvalidClient,
+ description: "The client_id metadata document could not be retrieved.",
+ uri: null);
+
+ return;
+ }
+
+ // Enforce size limit: read the response body with a size check.
+ var sizeLimit = options.ClientIdMetadataDocumentSizeLimit;
+ var content = response.Content;
+
+#if SUPPORTS_STREAM_MEMORY_METHODS
+ using var stream = await content.ReadAsStreamAsync(context.CancellationToken);
+#else
+ using var stream = await content.ReadAsStreamAsync();
+#endif
+ var buffer = new byte[sizeLimit + 1];
+ var totalRead = 0;
+ int bytesRead;
+
+ while (totalRead < buffer.Length &&
+ (bytesRead = await stream.ReadAsync(buffer.AsMemory(totalRead, buffer.Length - totalRead), context.CancellationToken)) > 0)
+ {
+ totalRead += bytesRead;
+ }
+
+ if (totalRead > sizeLimit)
+ {
+ context.Logger.LogWarning(
+ "The CIMD metadata document for client_id '{ClientId}' exceeds the maximum allowed size of {SizeLimit} bytes.",
+ clientId, sizeLimit);
+
+ context.Reject(
+ error: Errors.InvalidClient,
+ description: "The client_id metadata document exceeds the maximum allowed size.",
+ uri: null);
+
+ return;
+ }
+
+ // Parse the JSON metadata document.
+ JsonDocument document;
+ try
+ {
+ document = JsonDocument.Parse(buffer.AsMemory(0, totalRead));
+ }
+ catch (JsonException)
+ {
+ context.Logger.LogWarning(
+ "The CIMD metadata document for client_id '{ClientId}' is not valid JSON.",
+ clientId);
+
+ context.Reject(
+ error: Errors.InvalidClient,
+ description: "The client_id metadata document is not valid JSON.",
+ uri: null);
+
+ return;
+ }
+
+ // Validate that the document contains a client_id field matching the URL (exact string comparison).
+ if (!document.RootElement.TryGetProperty("client_id", out var clientIdElement) ||
+ clientIdElement.ValueKind != JsonValueKind.String ||
+ !string.Equals(clientIdElement.GetString(), clientId, StringComparison.Ordinal))
+ {
+ context.Logger.LogWarning(
+ "The CIMD metadata document's client_id field does not match the expected value '{ClientId}'.",
+ clientId);
+
+ context.Reject(
+ error: Errors.InvalidClient,
+ description: "The client_id in the metadata document does not match the requested client_id.",
+ uri: null);
+
+ document.Dispose();
+ return;
+ }
+
+ // Validate that the client does not use forbidden authentication methods.
+ // CIMD clients MUST NOT use client_secret_post, client_secret_basic, or client_secret_jwt.
+ if (document.RootElement.TryGetProperty("token_endpoint_auth_method", out var authMethodElement) &&
+ authMethodElement.ValueKind == JsonValueKind.String)
+ {
+ var authMethod = authMethodElement.GetString();
+ if (string.Equals(authMethod, "client_secret_post", StringComparison.Ordinal) ||
+ string.Equals(authMethod, "client_secret_basic", StringComparison.Ordinal) ||
+ string.Equals(authMethod, "client_secret_jwt", StringComparison.Ordinal))
+ {
+ context.Logger.LogWarning(
+ "The CIMD metadata document for client_id '{ClientId}' specifies a forbidden authentication method '{AuthMethod}'.",
+ clientId, authMethod);
+
+ context.Reject(
+ error: Errors.InvalidClient,
+ description: "The client_id metadata document specifies a forbidden authentication method.",
+ uri: null);
+
+ document.Dispose();
+ return;
+ }
+ }
+
+ // Store the parsed metadata document on the transaction properties for use by subsequent handlers.
+ context.Transaction.Properties[OpenIddictServerSystemNetHttpConstants.Properties.ClientIdMetadataDocument] = document;
+
+ context.Logger.LogInformation(
+ "The CIMD metadata document for client_id '{ClientId}' was successfully fetched and validated.",
+ clientId);
+ }
+ }
+ }
+}
diff --git a/src/OpenIddict.Server.SystemNetHttp/OpenIddictServerSystemNetHttpHandlers.cs b/src/OpenIddict.Server.SystemNetHttp/OpenIddictServerSystemNetHttpHandlers.cs
new file mode 100644
index 00000000..edfeb6b2
--- /dev/null
+++ b/src/OpenIddict.Server.SystemNetHttp/OpenIddictServerSystemNetHttpHandlers.cs
@@ -0,0 +1,29 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
+ * See https://github.com/openiddict/openiddict-core for more information concerning
+ * the license and the contributors participating to this project.
+ */
+
+using System.Collections.Immutable;
+using System.ComponentModel;
+using System.Diagnostics;
+using System.IO.Compression;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using static OpenIddict.Server.SystemNetHttp.OpenIddictServerSystemNetHttpConstants;
+
+namespace OpenIddict.Server.SystemNetHttp;
+
+[EditorBrowsable(EditorBrowsableState.Never)]
+public static partial class OpenIddictServerSystemNetHttpHandlers
+{
+ public static ImmutableArray DefaultHandlers { get; } =
+ [
+ .. Authentication.DefaultHandlers
+ ];
+}
diff --git a/src/OpenIddict.Server.SystemNetHttp/OpenIddictServerSystemNetHttpHelpers.cs b/src/OpenIddict.Server.SystemNetHttp/OpenIddictServerSystemNetHttpHelpers.cs
new file mode 100644
index 00000000..cf055bb5
--- /dev/null
+++ b/src/OpenIddict.Server.SystemNetHttp/OpenIddictServerSystemNetHttpHelpers.cs
@@ -0,0 +1,39 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
+ * See https://github.com/openiddict/openiddict-core for more information concerning
+ * the license and the contributors participating to this project.
+ */
+
+using OpenIddict.Server;
+
+namespace System.Net.Http;
+
+///
+/// Exposes companion extensions for the OpenIddict server/System.Net.Http integration.
+///
+public static class OpenIddictServerSystemNetHttpHelpers
+{
+ ///
+ /// Gets the associated with the current context.
+ ///
+ /// The transaction instance.
+ /// The instance or if it couldn't be found.
+ public static HttpClient? GetHttpClient(this OpenIddictServerTransaction transaction)
+ => transaction.GetProperty(typeof(HttpClient).FullName!);
+
+ ///
+ /// Gets the associated with the current context.
+ ///
+ /// The transaction instance.
+ /// The instance or if it couldn't be found.
+ public static HttpRequestMessage? GetHttpRequestMessage(this OpenIddictServerTransaction transaction)
+ => transaction.GetProperty(typeof(HttpRequestMessage).FullName!);
+
+ ///
+ /// Gets the associated with the current context.
+ ///
+ /// The transaction instance.
+ /// The instance or if it couldn't be found.
+ public static HttpResponseMessage? GetHttpResponseMessage(this OpenIddictServerTransaction transaction)
+ => transaction.GetProperty(typeof(HttpResponseMessage).FullName!);
+}
diff --git a/src/OpenIddict.Server.SystemNetHttp/OpenIddictServerSystemNetHttpOptions.cs b/src/OpenIddict.Server.SystemNetHttp/OpenIddictServerSystemNetHttpOptions.cs
new file mode 100644
index 00000000..8ea0c7e6
--- /dev/null
+++ b/src/OpenIddict.Server.SystemNetHttp/OpenIddictServerSystemNetHttpOptions.cs
@@ -0,0 +1,83 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
+ * See https://github.com/openiddict/openiddict-core for more information concerning
+ * the license and the contributors participating to this project.
+ */
+
+using System.Net;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Net.Mail;
+using Polly;
+using Polly.Extensions.Http;
+
+#if SUPPORTS_HTTP_CLIENT_RESILIENCE
+using Microsoft.Extensions.Http.Resilience;
+#endif
+
+namespace OpenIddict.Server.SystemNetHttp;
+
+///
+/// Provides various settings needed to configure the OpenIddict server/System.Net.Http integration.
+///
+public sealed class OpenIddictServerSystemNetHttpOptions
+{
+ ///
+ /// Gets or sets the HTTP Polly error policy used by the internal OpenIddict HTTP clients.
+ ///
+ ///
+ /// Note: on .NET 8.0 and higher, this property is set to by default.
+ ///
+ public IAsyncPolicy? HttpErrorPolicy { get; set; }
+#if !SUPPORTS_HTTP_CLIENT_RESILIENCE
+ = HttpPolicyExtensions.HandleTransientHttpError()
+ .OrResult(static response => response.StatusCode is HttpStatusCode.NotFound)
+ .WaitAndRetryAsync(4, static attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)));
+#endif
+
+#if SUPPORTS_HTTP_CLIENT_RESILIENCE
+ ///
+ /// Gets or sets the HTTP resilience pipeline used by the internal OpenIddict HTTP clients.
+ ///
+ ///
+ /// Note: this property is not used when
+ /// is explicitly set to a non- value.
+ ///
+ public ResiliencePipeline? HttpResiliencePipeline { get; set; }
+ = new ResiliencePipelineBuilder()
+ .AddRetry(new HttpRetryStrategyOptions
+ {
+ DelayGenerator = static arguments => new(
+ TimeSpan.FromSeconds(Math.Pow(2, arguments.AttemptNumber))),
+ MaxRetryAttempts = 4,
+ ShouldHandle = static arguments => new(
+ HttpClientResiliencePredicates.IsTransient(arguments.Outcome) ||
+ arguments.Outcome.Result?.StatusCode is HttpStatusCode.NotFound)
+ })
+ .Build();
+#endif
+
+ ///
+ /// Gets or sets the contact mail address used in the "From" header that is
+ /// attached to the HTTP requests sent by the OpenIddict server services.
+ ///
+ public MailAddress? ContactAddress { get; set; }
+
+ ///
+ /// Gets or sets the product information used in the "User-Agent" header that is
+ /// attached to the HTTP requests sent by the OpenIddict server services.
+ ///
+ public ProductInfoHeaderValue? ProductInformation { get; set; }
+
+ ///
+ /// Gets the user-defined actions used to amend the
+ /// instances created by the OpenIddict server/System.Net.Http integration.
+ ///
+ public List> HttpClientActions { get; } = [];
+
+ ///
+ /// Gets the user-defined actions used to amend the
+ /// instances created by the OpenIddict server/System.Net.Http integration.
+ ///
+ public List> HttpClientHandlerActions { get; } = [];
+}
diff --git a/src/OpenIddict.Server/OpenIddictServerBuilder.cs b/src/OpenIddict.Server/OpenIddictServerBuilder.cs
index f0ec51fd..b51500ee 100644
--- a/src/OpenIddict.Server/OpenIddictServerBuilder.cs
+++ b/src/OpenIddict.Server/OpenIddictServerBuilder.cs
@@ -1943,6 +1943,15 @@ public sealed class OpenIddictServerBuilder
public OpenIddictServerBuilder UseReferenceRefreshTokens()
=> Configure(options => options.UseReferenceRefreshTokens = true);
+ ///
+ /// Enables support for client ID metadata documents (CIMD), allowing clients
+ /// to use an HTTPS URL as their client identifier. The server will fetch the
+ /// metadata document from that URL when the client is not pre-registered.
+ ///
+ /// The instance.
+ public OpenIddictServerBuilder EnableClientIdMetadataDocumentSupport()
+ => Configure(options => options.EnableClientIdMetadataDocumentSupport = true);
+
///
/// Enables authorization request storage, so that authorization requests
/// are automatically stored in the token store, which allows flowing
diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs
index 7ca85859..cf307c47 100644
--- a/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs
+++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs
@@ -1391,6 +1391,13 @@ public static partial class OpenIddictServerHandlers
if (!context.Options.EnableDegradedMode)
{
+ // Skip for CIMD clients (they are public, so the confidential-client downgrade check doesn't apply).
+ if (context.Transaction.Properties.TryGetValue(
+ ".ClientIdMetadataDocumentFetchRequired", out var cimdFlag) && cimdFlag is true)
+ {
+ return;
+ }
+
if (_applicationManager is null)
{
throw new InvalidOperationException(SR.GetResourceString(SR.ID0016));
@@ -1449,6 +1456,74 @@ public static partial class OpenIddictServerHandlers
Debug.Assert(!string.IsNullOrEmpty(context.ClientId), SR.FormatID4000(Parameters.ClientId));
+ // For CIMD clients, validate the redirect_uri against the metadata document.
+ if (context.Transaction.Properties.TryGetValue(
+ ".ClientIdMetadataDocumentFetchRequired", out var cimdFlag) && cimdFlag is true)
+ {
+ // A redirect_uri is always required for CIMD clients.
+ if (string.IsNullOrEmpty(context.RedirectUri))
+ {
+ context.Logger.LogInformation(6033, SR.GetResourceString(SR.ID6033), Parameters.RedirectUri);
+
+ context.Reject(
+ error: Errors.InvalidRequest,
+ description: SR.FormatID2029(Parameters.RedirectUri),
+ uri: SR.FormatID8000(SR.ID2029));
+
+ return;
+ }
+
+ // Retrieve the CIMD metadata document from the transaction properties.
+ if (!context.Transaction.Properties.TryGetValue(
+ ".ClientIdMetadataDocument", out var documentObj) ||
+ documentObj is not System.Text.Json.JsonDocument document)
+ {
+ context.Reject(
+ error: Errors.InvalidClient,
+ description: "The client_id metadata document is not available.",
+ uri: null);
+
+ return;
+ }
+
+ // Validate that the redirect_uri matches one from the metadata document.
+ if (!document.RootElement.TryGetProperty("redirect_uris", out var redirectUrisElement) ||
+ redirectUrisElement.ValueKind != System.Text.Json.JsonValueKind.Array)
+ {
+ context.Reject(
+ error: Errors.InvalidClient,
+ description: "The client_id metadata document does not contain redirect_uris.",
+ uri: null);
+
+ return;
+ }
+
+ var redirectUriFound = false;
+ foreach (var element in redirectUrisElement.EnumerateArray())
+ {
+ if (element.ValueKind == System.Text.Json.JsonValueKind.String &&
+ string.Equals(element.GetString(), context.RedirectUri, StringComparison.Ordinal))
+ {
+ redirectUriFound = true;
+ break;
+ }
+ }
+
+ if (!redirectUriFound)
+ {
+ context.Logger.LogInformation(6046, SR.GetResourceString(SR.ID6046), context.RedirectUri);
+
+ context.Reject(
+ error: Errors.InvalidRequest,
+ description: SR.FormatID2043(Parameters.RedirectUri),
+ uri: SR.FormatID8000(SR.ID2043));
+
+ return;
+ }
+
+ return;
+ }
+
var application = await _applicationManager.FindByClientIdAsync(context.ClientId) ??
throw new InvalidOperationException(SR.GetResourceString(SR.ID0032));
@@ -1638,6 +1713,13 @@ public static partial class OpenIddictServerHandlers
Debug.Assert(!string.IsNullOrEmpty(context.ClientId), SR.FormatID4000(Parameters.ClientId));
+ // Skip for CIMD clients (no pre-registered application to look up).
+ if (context.Transaction.Properties.TryGetValue(
+ ".ClientIdMetadataDocumentFetchRequired", out var cimdFlag) && cimdFlag is true)
+ {
+ return;
+ }
+
var application = await _applicationManager.FindByClientIdAsync(context.ClientId) ??
throw new InvalidOperationException(SR.GetResourceString(SR.ID0032));
@@ -1688,6 +1770,13 @@ public static partial class OpenIddictServerHandlers
Debug.Assert(!string.IsNullOrEmpty(context.ClientId), SR.FormatID4000(Parameters.ClientId));
+ // Skip for CIMD clients (no pre-registered application to look up).
+ if (context.Transaction.Properties.TryGetValue(
+ ".ClientIdMetadataDocumentFetchRequired", out var cimdFlag) && cimdFlag is true)
+ {
+ return;
+ }
+
var application = await _applicationManager.FindByClientIdAsync(context.ClientId) ??
throw new InvalidOperationException(SR.GetResourceString(SR.ID0032));
@@ -1783,6 +1872,13 @@ public static partial class OpenIddictServerHandlers
Debug.Assert(!string.IsNullOrEmpty(context.ClientId), SR.FormatID4000(Parameters.ClientId));
+ // Skip for CIMD clients (no pre-registered application to look up).
+ if (context.Transaction.Properties.TryGetValue(
+ ".ClientIdMetadataDocumentFetchRequired", out var cimdFlag) && cimdFlag is true)
+ {
+ return;
+ }
+
var application = await _applicationManager.FindByClientIdAsync(context.ClientId) ??
throw new InvalidOperationException(SR.GetResourceString(SR.ID0032));
@@ -1858,6 +1954,13 @@ public static partial class OpenIddictServerHandlers
Debug.Assert(!string.IsNullOrEmpty(context.ClientId), SR.FormatID4000(Parameters.ClientId));
+ // Skip for CIMD clients (no pre-registered application to look up).
+ if (context.Transaction.Properties.TryGetValue(
+ ".ClientIdMetadataDocumentFetchRequired", out var cimdFlag) && cimdFlag is true)
+ {
+ return;
+ }
+
var application = await _applicationManager.FindByClientIdAsync(context.ClientId) ??
throw new InvalidOperationException(SR.GetResourceString(SR.ID0032));
@@ -1919,6 +2022,13 @@ public static partial class OpenIddictServerHandlers
Debug.Assert(!string.IsNullOrEmpty(context.ClientId), SR.FormatID4000(Parameters.ClientId));
+ // Skip for CIMD clients (no pre-registered application to look up).
+ if (context.Transaction.Properties.TryGetValue(
+ ".ClientIdMetadataDocumentFetchRequired", out var cimdFlag) && cimdFlag is true)
+ {
+ return;
+ }
+
var application = await _applicationManager.FindByClientIdAsync(context.ClientId) ??
throw new InvalidOperationException(SR.GetResourceString(SR.ID0032));
@@ -1972,6 +2082,13 @@ public static partial class OpenIddictServerHandlers
Debug.Assert(!string.IsNullOrEmpty(context.ClientId), SR.FormatID4000(Parameters.ClientId));
+ // Skip for CIMD clients (no pre-registered application to look up).
+ if (context.Transaction.Properties.TryGetValue(
+ ".ClientIdMetadataDocumentFetchRequired", out var cimdFlag) && cimdFlag is true)
+ {
+ return;
+ }
+
// If a request token principal with the correct type could be extracted, the request is always
// considered valid, whether the pushed authorization requests requirement is enforced or not.
var type = context.RequestTokenPrincipal?.GetClaim(Claims.Private.RequestTokenType);
@@ -2039,6 +2156,13 @@ public static partial class OpenIddictServerHandlers
Debug.Assert(!string.IsNullOrEmpty(context.ClientId), SR.FormatID4000(Parameters.ClientId));
+ // Skip for CIMD clients (no pre-registered application to look up).
+ if (context.Transaction.Properties.TryGetValue(
+ ".ClientIdMetadataDocumentFetchRequired", out var cimdFlag) && cimdFlag is true)
+ {
+ return;
+ }
+
// If a code_challenge was provided or if no authorization code is requested, the request is always
// considered valid, whether the proof key for code exchange requirement is enforced or not.
if (!string.IsNullOrEmpty(context.Request.CodeChallenge) || !context.Request.HasResponseType(ResponseTypes.Code))
diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Discovery.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Discovery.cs
index 2b572c28..4da501ed 100644
--- a/src/OpenIddict.Server/OpenIddictServerHandlers.Discovery.cs
+++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Discovery.cs
@@ -795,6 +795,12 @@ public static partial class OpenIddictServerHandlers
context.Metadata[Metadata.RequestUriParameterSupported] = false;
context.Metadata[Metadata.TlsClientCertificateBoundAccessTokens] = false;
+ // If CIMD (Client ID Metadata Document) support is enabled, advertise it in the discovery document.
+ if (context.Options.EnableClientIdMetadataDocumentSupport)
+ {
+ context.Metadata[Metadata.ClientIdMetadataDocumentSupported] = true;
+ }
+
// As of 3.2.0, OpenIddict automatically returns an "iss" parameter containing its identity as
// part of authorization responses to help clients mitigate mix-up attacks. For more information,
// see https://datatracker.ietf.org/doc/html/draft-ietf-oauth-iss-auth-resp-05.
diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Protection.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Protection.cs
index 494428dc..99b84f46 100644
--- a/src/OpenIddict.Server/OpenIddictServerHandlers.Protection.cs
+++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Protection.cs
@@ -1438,7 +1438,10 @@ public static partial class OpenIddictServerHandlers
};
// If the client application is known, associate it with the token.
- if (!string.IsNullOrEmpty(context.ClientId))
+ // For CIMD clients, there is no pre-registered application entity.
+ if (!string.IsNullOrEmpty(context.ClientId) &&
+ !(context.Transaction.Properties.TryGetValue(
+ ".ClientIdMetadataDocumentFetchRequired", out var cimdFlag) && cimdFlag is true))
{
var application = await _applicationManager.FindByClientIdAsync(context.ClientId) ??
throw new InvalidOperationException(SR.GetResourceString(SR.ID0017));
diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.cs
index 58ffa3af..16c8b6d8 100644
--- a/src/OpenIddict.Server/OpenIddictServerHandlers.cs
+++ b/src/OpenIddict.Server/OpenIddictServerHandlers.cs
@@ -1033,6 +1033,26 @@ public static partial class OpenIddictServerHandlers
var application = await _applicationManager.FindByClientIdAsync(context.ClientId);
if (application is null)
{
+ // If CIMD support is enabled and the client_id is a valid HTTPS URL,
+ // flag the transaction for metadata document fetching instead of rejecting.
+ if (context.Options.EnableClientIdMetadataDocumentSupport &&
+ Uri.TryCreate(context.ClientId, UriKind.Absolute, out var clientUri) &&
+ string.Equals(clientUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) &&
+ !string.IsNullOrEmpty(clientUri.AbsolutePath) &&
+ clientUri.AbsolutePath is not "/" &&
+ string.IsNullOrEmpty(clientUri.Fragment) &&
+ string.IsNullOrEmpty(clientUri.UserInfo))
+ {
+ context.Logger.LogInformation(
+ "The client_id '{ClientId}' was not found in the store but matches CIMD URL format. " +
+ "A metadata document fetch will be attempted.", context.ClientId);
+
+ // Flag the transaction so the CIMD fetch handler knows to process this request.
+ context.Transaction.Properties[".ClientIdMetadataDocumentFetchRequired"] = true;
+
+ return;
+ }
+
context.Logger.LogInformation(6221, SR.GetResourceString(SR.ID6221), context.ClientId);
context.Reject(
@@ -1097,6 +1117,25 @@ public static partial class OpenIddictServerHandlers
return;
}
+ // Skip client type validation for CIMD clients (they are treated as public clients).
+ if (context.Transaction.Properties.TryGetValue(
+ ".ClientIdMetadataDocumentFetchRequired", out var cimdFlag) && cimdFlag is true)
+ {
+ // CIMD clients MUST NOT use client_secret_post, client_secret_basic, or client_secret_jwt.
+ // Reject requests containing a client_secret when the client is a CIMD client.
+ if (!string.IsNullOrEmpty(context.ClientSecret))
+ {
+ context.Reject(
+ error: Errors.InvalidClient,
+ description: "CIMD clients cannot use client secret authentication.",
+ uri: null);
+
+ return;
+ }
+
+ return;
+ }
+
var application = await _applicationManager.FindByClientIdAsync(context.ClientId) ??
throw new InvalidOperationException(SR.GetResourceString(SR.ID0032));
@@ -3206,7 +3245,10 @@ public static partial class OpenIddictServerHandlers
descriptor.Scopes.UnionWith(context.Principal.GetScopes());
// If the client application is known, associate it to the authorization.
- if (!string.IsNullOrEmpty(context.Request.ClientId))
+ // For CIMD clients, there is no pre-registered application entity.
+ if (!string.IsNullOrEmpty(context.Request.ClientId) &&
+ !(context.Transaction.Properties.TryGetValue(
+ ".ClientIdMetadataDocumentFetchRequired", out var cimdFlag) && cimdFlag is true))
{
var application = await _applicationManager.FindByClientIdAsync(context.Request.ClientId) ??
throw new InvalidOperationException(SR.GetResourceString(SR.ID0017));
@@ -3339,7 +3381,10 @@ public static partial class OpenIddictServerHandlers
var lifetime = context.Principal.GetAccessTokenLifetime();
// If the client to which the token is returned is known, use the attached setting if available.
- if (lifetime is null && !context.Options.EnableDegradedMode && !string.IsNullOrEmpty(context.ClientId))
+ // Skip for CIMD clients (no pre-registered application to look up).
+ if (lifetime is null && !context.Options.EnableDegradedMode && !string.IsNullOrEmpty(context.ClientId) &&
+ !(context.Transaction.Properties.TryGetValue(
+ ".ClientIdMetadataDocumentFetchRequired", out var cimdFlag) && cimdFlag is true))
{
if (_applicationManager is null)
{
@@ -3466,7 +3511,10 @@ public static partial class OpenIddictServerHandlers
var lifetime = context.Principal.GetAuthorizationCodeLifetime();
// If the client to which the token is returned is known, use the attached setting if available.
- if (lifetime is null && !context.Options.EnableDegradedMode && !string.IsNullOrEmpty(context.ClientId))
+ // Skip for CIMD clients (no pre-registered application to look up).
+ if (lifetime is null && !context.Options.EnableDegradedMode && !string.IsNullOrEmpty(context.ClientId) &&
+ !(context.Transaction.Properties.TryGetValue(
+ ".ClientIdMetadataDocumentFetchRequired", out var cimdFlag) && cimdFlag is true))
{
if (_applicationManager is null)
{
@@ -3595,7 +3643,10 @@ public static partial class OpenIddictServerHandlers
var lifetime = context.Principal.GetDeviceCodeLifetime();
// If the client to which the token is returned is known, use the attached setting if available.
- if (lifetime is null && !context.Options.EnableDegradedMode && !string.IsNullOrEmpty(context.ClientId))
+ // Skip for CIMD clients (no pre-registered application to look up).
+ if (lifetime is null && !context.Options.EnableDegradedMode && !string.IsNullOrEmpty(context.ClientId) &&
+ !(context.Transaction.Properties.TryGetValue(
+ ".ClientIdMetadataDocumentFetchRequired", out var cimdFlag) && cimdFlag is true))
{
if (_applicationManager is null)
{
@@ -3839,7 +3890,10 @@ public static partial class OpenIddictServerHandlers
};
// If the client to which the token is returned is known, use the attached setting if available.
- if (lifetime is null && !context.Options.EnableDegradedMode && !string.IsNullOrEmpty(context.ClientId))
+ // Skip for CIMD clients (no pre-registered application to look up).
+ if (lifetime is null && !context.Options.EnableDegradedMode && !string.IsNullOrEmpty(context.ClientId) &&
+ !(context.Transaction.Properties.TryGetValue(
+ ".ClientIdMetadataDocumentFetchRequired", out var cimdFlag) && cimdFlag is true))
{
if (_applicationManager is null)
{
@@ -3980,7 +4034,10 @@ public static partial class OpenIddictServerHandlers
var lifetime = context.Principal.GetRequestTokenLifetime();
// If the client to which the token is returned is known, use the attached setting if available.
- if (lifetime is null && !context.Options.EnableDegradedMode && !string.IsNullOrEmpty(context.ClientId))
+ // Skip for CIMD clients (no pre-registered application to look up).
+ if (lifetime is null && !context.Options.EnableDegradedMode && !string.IsNullOrEmpty(context.ClientId) &&
+ !(context.Transaction.Properties.TryGetValue(
+ ".ClientIdMetadataDocumentFetchRequired", out var cimdFlag) && cimdFlag is true))
{
if (_applicationManager is null)
{
@@ -4131,7 +4188,10 @@ public static partial class OpenIddictServerHandlers
var lifetime = context.Principal.GetRefreshTokenLifetime();
// If the client to which the token is returned is known, use the attached setting if available.
- if (lifetime is null && !context.Options.EnableDegradedMode && !string.IsNullOrEmpty(context.ClientId))
+ // Skip for CIMD clients (no pre-registered application to look up).
+ if (lifetime is null && !context.Options.EnableDegradedMode && !string.IsNullOrEmpty(context.ClientId) &&
+ !(context.Transaction.Properties.TryGetValue(
+ ".ClientIdMetadataDocumentFetchRequired", out var cimdFlag) && cimdFlag is true))
{
if (_applicationManager is null)
{
@@ -4268,7 +4328,10 @@ public static partial class OpenIddictServerHandlers
var lifetime = context.Principal.GetIdentityTokenLifetime();
// If the client to which the token is returned is known, use the attached setting if available.
- if (lifetime is null && !context.Options.EnableDegradedMode && !string.IsNullOrEmpty(context.ClientId))
+ // Skip for CIMD clients (no pre-registered application to look up).
+ if (lifetime is null && !context.Options.EnableDegradedMode && !string.IsNullOrEmpty(context.ClientId) &&
+ !(context.Transaction.Properties.TryGetValue(
+ ".ClientIdMetadataDocumentFetchRequired", out var cimdFlag) && cimdFlag is true))
{
if (_applicationManager is null)
{
@@ -4398,7 +4461,10 @@ public static partial class OpenIddictServerHandlers
var lifetime = context.Principal.GetUserCodeLifetime();
// If the client to which the token is returned is known, use the attached setting if available.
- if (lifetime is null && !context.Options.EnableDegradedMode && !string.IsNullOrEmpty(context.ClientId))
+ // Skip for CIMD clients (no pre-registered application to look up).
+ if (lifetime is null && !context.Options.EnableDegradedMode && !string.IsNullOrEmpty(context.ClientId) &&
+ !(context.Transaction.Properties.TryGetValue(
+ ".ClientIdMetadataDocumentFetchRequired", out var cimdFlag) && cimdFlag is true))
{
if (_applicationManager is null)
{
diff --git a/src/OpenIddict.Server/OpenIddictServerOptions.cs b/src/OpenIddict.Server/OpenIddictServerOptions.cs
index 58f9aaad..52edd510 100644
--- a/src/OpenIddict.Server/OpenIddictServerOptions.cs
+++ b/src/OpenIddict.Server/OpenIddictServerOptions.cs
@@ -370,6 +370,32 @@ public sealed class OpenIddictServerOptions
///
public bool DisableScopeValidation { get; set; }
+ ///
+ /// Gets or sets a boolean indicating whether support for client ID metadata documents
+ /// (CIMD) is enabled. When enabled, the server accepts HTTPS URLs as client identifiers
+ /// and fetches the client metadata from the specified URL when the client is not
+ /// pre-registered. For more information, see draft-ietf-oauth-client-id-metadata-document.
+ ///
+ public bool EnableClientIdMetadataDocumentSupport { get; set; }
+
+ ///
+ /// Gets or sets the maximum size, in bytes, of a client ID metadata document
+ /// that the server will accept. The default value is 5120 (5 KB).
+ ///
+ public int ClientIdMetadataDocumentSizeLimit { get; set; } = 5_120;
+
+ ///
+ /// Gets or sets the timeout for fetching a client ID metadata document.
+ /// The default value is 10 seconds.
+ ///
+ public TimeSpan ClientIdMetadataDocumentFetchTimeout { get; set; } = TimeSpan.FromSeconds(10);
+
+ ///
+ /// Gets or sets the default cache duration for client ID metadata documents
+ /// when no HTTP cache headers are present. The default value is 1 hour.
+ ///
+ public TimeSpan ClientIdMetadataDocumentDefaultCacheDuration { get; set; } = TimeSpan.FromHours(1);
+
///
/// Gets or sets a boolean indicating whether requests received by the authorization
/// endpoint should be stored in the token store, which allows flowing