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