Browse Source

Fix CIMD token exchange by moving fetch handler to ProcessAuthenticationContext

The CIMD metadata document fetch was running in the outer
ValidateAuthorizationRequestContext pipeline, which only covers the
authorize endpoint. During token exchange, ValidateClientType runs
inside ProcessAuthenticationContext (before the outer pipeline handler)
and calls FindByClientIdAsync — which returns null because the CIMD
context was never populated for that request.

Move FetchClientIdMetadataDocument to target ProcessAuthenticationContext
with order between ValidateClientId and ValidateClientType. This ensures
the CIMD document is fetched for all endpoint types (authorize, token,
etc.) before the client type validation occurs.
pull/2416/head
Thor Arne Johansen 6 days ago
parent
commit
aa93d7111e
  1. 12
      sandbox/OpenIddict.Sandbox.AspNetCore.CimdServer/AuthorizationController.cs
  2. 1
      src/OpenIddict.Server.SystemNetHttp/OpenIddict.Server.SystemNetHttp.csproj
  3. 225
      src/OpenIddict.Server.SystemNetHttp/OpenIddictServerSystemNetHttpApplicationManager.cs
  4. 34
      src/OpenIddict.Server.SystemNetHttp/OpenIddictServerSystemNetHttpCimdContext.cs
  5. 11
      src/OpenIddict.Server.SystemNetHttp/OpenIddictServerSystemNetHttpExtensions.cs
  6. 184
      src/OpenIddict.Server.SystemNetHttp/OpenIddictServerSystemNetHttpHandlers.Authentication.cs
  7. 182
      src/OpenIddict.Server.SystemNetHttp/OpenIddictServerSystemNetHttpHandlers.cs
  8. 117
      src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs
  9. 5
      src/OpenIddict.Server/OpenIddictServerHandlers.Protection.cs
  10. 64
      src/OpenIddict.Server/OpenIddictServerHandlers.cs

12
sandbox/OpenIddict.Sandbox.AspNetCore.CimdServer/AuthorizationController.cs

@ -79,15 +79,19 @@ public class AuthorizationController : Controller
identity.SetScopes(request.GetScopes());
identity.SetResources(await _scopeManager.ListResourcesAsync(identity.GetScopes()).ToListAsync());
// For CIMD clients (URL-based client_id with no pre-registration), skip
// the application lookup and authorization entry creation.
// Look up the application (for CIMD clients, this returns a virtual application
// synthesized from the metadata document by the CIMD application manager).
var application = await _applicationManager.FindByClientIdAsync(request.ClientId!);
if (application is not null)
var applicationId = application is not null ? await _applicationManager.GetIdAsync(application) : null;
// Only create an authorization entry if the application has a database identity
// (CIMD virtual applications return null for GetIdAsync).
if (!string.IsNullOrEmpty(applicationId))
{
var authorization = await _authorizationManager.CreateAsync(
identity: identity,
subject: await _userManager.GetUserIdAsync(userEntity),
client: (await _applicationManager.GetIdAsync(application))!,
client: applicationId,
type: AuthorizationTypes.Permanent,
scopes: identity.GetScopes());

1
src/OpenIddict.Server.SystemNetHttp/OpenIddict.Server.SystemNetHttp.csproj

@ -14,6 +14,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\OpenIddict.Core\OpenIddict.Core.csproj" />
<ProjectReference Include="..\OpenIddict.Server\OpenIddict.Server.csproj" />
</ItemGroup>

225
src/OpenIddict.Server.SystemNetHttp/OpenIddictServerSystemNetHttpApplicationManager.cs

@ -0,0 +1,225 @@
/*
* 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.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using OpenIddict.Core;
namespace OpenIddict.Server.SystemNetHttp;
/// <summary>
/// A CIMD-aware application manager that synthesizes virtual applications from
/// Client ID Metadata Documents. When <c>FindByClientIdAsync</c> returns null
/// from the underlying store and a CIMD metadata document has been fetched for
/// the current request, this manager returns a synthesized virtual application
/// populated with the metadata from the document.
/// </summary>
/// <typeparam name="TApplication">The type of the Application entity.</typeparam>
public class OpenIddictServerSystemNetHttpApplicationManager<TApplication>
: OpenIddictApplicationManager<TApplication> where TApplication : class
{
private readonly OpenIddictServerSystemNetHttpCimdContext _cimdContext;
public OpenIddictServerSystemNetHttpApplicationManager(
IOpenIddictApplicationCache<TApplication> cache,
ILogger<OpenIddictApplicationManager<TApplication>> logger,
IOptionsMonitor<OpenIddictCoreOptions> options,
IOpenIddictApplicationStore<TApplication> store,
OpenIddictServerSystemNetHttpCimdContext cimdContext)
: base(cache, logger, options, store)
{
_cimdContext = cimdContext ?? throw new ArgumentNullException(nameof(cimdContext));
}
/// <inheritdoc/>
public override async ValueTask<TApplication?> FindByClientIdAsync(
string identifier, CancellationToken cancellationToken = default)
{
// First, try the base implementation (pre-registered clients always win).
var application = await base.FindByClientIdAsync(identifier, cancellationToken);
if (application is not null)
{
return application;
}
// If the base returned null, check if we have a CIMD context for this client_id.
if (!string.Equals(_cimdContext.ClientId, identifier, StringComparison.Ordinal) ||
_cimdContext.MetadataDocument is null)
{
return null;
}
// Return cached virtual application if already synthesized.
if (_cimdContext.VirtualApplication is TApplication cached)
{
return cached;
}
// Synthesize a virtual application from the CIMD metadata document.
var virtualApp = await SynthesizeVirtualApplicationAsync(
identifier, _cimdContext.MetadataDocument, cancellationToken);
_cimdContext.VirtualApplication = virtualApp;
return virtualApp;
}
/// <inheritdoc/>
public override ValueTask<string?> GetIdAsync(
TApplication application, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(application);
// Virtual applications have no database identity.
if (ReferenceEquals(application, _cimdContext.VirtualApplication))
{
return new ValueTask<string?>((string?) null);
}
return base.GetIdAsync(application, cancellationToken);
}
private async ValueTask<TApplication> SynthesizeVirtualApplicationAsync(
string clientId, JsonDocument document, CancellationToken cancellationToken)
{
var application = await Store.InstantiateAsync(cancellationToken);
await Store.SetClientIdAsync(application, clientId, cancellationToken);
await Store.SetClientTypeAsync(application, ClientTypes.Public, cancellationToken);
// Set application type from metadata, defaulting to "web".
var applicationType = ApplicationTypes.Web;
if (document.RootElement.TryGetProperty("application_type", out var appTypeElement) &&
appTypeElement.ValueKind == JsonValueKind.String)
{
applicationType = appTypeElement.GetString() ?? ApplicationTypes.Web;
}
await Store.SetApplicationTypeAsync(application, applicationType, cancellationToken);
// Set display name from metadata if present.
if (document.RootElement.TryGetProperty("client_name", out var clientNameElement) &&
clientNameElement.ValueKind == JsonValueKind.String)
{
await Store.SetDisplayNameAsync(application, clientNameElement.GetString(), cancellationToken);
}
// Set redirect URIs from metadata.
var redirectUris = ImmutableArray.CreateBuilder<string>();
if (document.RootElement.TryGetProperty("redirect_uris", out var redirectUrisElement) &&
redirectUrisElement.ValueKind == JsonValueKind.Array)
{
foreach (var element in redirectUrisElement.EnumerateArray())
{
if (element.ValueKind == JsonValueKind.String)
{
var uri = element.GetString();
if (!string.IsNullOrEmpty(uri))
{
redirectUris.Add(uri);
}
}
}
}
await Store.SetRedirectUrisAsync(application, redirectUris.ToImmutable(), cancellationToken);
// Build permissions: grant all standard endpoint, grant_type, response_type,
// and scope permissions since CIMD clients aren't permission-restricted.
var permissions = ImmutableArray.CreateBuilder<string>();
// Endpoint permissions.
permissions.Add(Permissions.Endpoints.Authorization);
permissions.Add(Permissions.Endpoints.Token);
// Grant type permissions from metadata.
if (document.RootElement.TryGetProperty("grant_types", out var grantTypesElement) &&
grantTypesElement.ValueKind == JsonValueKind.Array)
{
foreach (var element in grantTypesElement.EnumerateArray())
{
if (element.ValueKind == JsonValueKind.String)
{
var grantType = element.GetString();
var permission = grantType switch
{
GrantTypes.AuthorizationCode => Permissions.GrantTypes.AuthorizationCode,
GrantTypes.Implicit => Permissions.GrantTypes.Implicit,
GrantTypes.RefreshToken => Permissions.GrantTypes.RefreshToken,
GrantTypes.DeviceCode => Permissions.GrantTypes.DeviceCode,
GrantTypes.ClientCredentials => Permissions.GrantTypes.ClientCredentials,
GrantTypes.TokenExchange => Permissions.GrantTypes.TokenExchange,
GrantTypes.Password => Permissions.GrantTypes.Password,
_ => null
};
if (permission is not null && !permissions.Contains(permission))
{
permissions.Add(permission);
}
}
}
}
else
{
// Default grant type if not specified.
permissions.Add(Permissions.GrantTypes.AuthorizationCode);
}
// Response type permissions from metadata.
if (document.RootElement.TryGetProperty("response_types", out var responseTypesElement) &&
responseTypesElement.ValueKind == JsonValueKind.Array)
{
foreach (var element in responseTypesElement.EnumerateArray())
{
if (element.ValueKind == JsonValueKind.String)
{
var responseType = element.GetString();
var permission = responseType switch
{
ResponseTypes.Code => Permissions.ResponseTypes.Code,
"code id_token" => Permissions.ResponseTypes.CodeIdToken,
"code id_token token" => Permissions.ResponseTypes.CodeIdTokenToken,
"code token" => Permissions.ResponseTypes.CodeToken,
"id_token" => Permissions.ResponseTypes.IdToken,
"id_token token" => Permissions.ResponseTypes.IdTokenToken,
"none" => Permissions.ResponseTypes.None,
"token" => Permissions.ResponseTypes.Token,
_ => null
};
if (permission is not null && !permissions.Contains(permission))
{
permissions.Add(permission);
}
}
}
}
else
{
// Default response type if not specified.
permissions.Add(Permissions.ResponseTypes.Code);
}
// Add common scope permissions.
permissions.Add(Permissions.Scopes.Email);
permissions.Add(Permissions.Scopes.Profile);
permissions.Add(Permissions.Scopes.Address);
permissions.Add(Permissions.Scopes.Phone);
permissions.Add(Permissions.Scopes.Roles);
await Store.SetPermissionsAsync(application, permissions.ToImmutable(), cancellationToken);
// Set requirements: PKCE is always required for CIMD clients.
await Store.SetRequirementsAsync(application,
[Requirements.Features.ProofKeyForCodeExchange], cancellationToken);
// Use empty settings (server defaults apply).
await Store.SetSettingsAsync(application, ImmutableDictionary<string, string>.Empty, cancellationToken);
return application;
}
}

34
src/OpenIddict.Server.SystemNetHttp/OpenIddictServerSystemNetHttpCimdContext.cs

@ -0,0 +1,34 @@
/*
* 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.Text.Json;
namespace OpenIddict.Server.SystemNetHttp;
/// <summary>
/// Provides a scoped context for Client ID Metadata Document (CIMD) support.
/// This context is used to store the fetched metadata document and the synthesized
/// virtual application for the current request.
/// </summary>
public sealed class OpenIddictServerSystemNetHttpCimdContext
{
/// <summary>
/// Gets or sets the client identifier (CIMD URL) for the current request.
/// </summary>
public string? ClientId { get; set; }
/// <summary>
/// Gets or sets the parsed CIMD metadata document for the current request.
/// </summary>
public JsonDocument? MetadataDocument { get; set; }
/// <summary>
/// Gets or sets the synthesized virtual application for the current request.
/// This is used as a per-request cache to avoid re-creating the virtual application
/// on each call to <c>FindByClientIdAsync</c>.
/// </summary>
public object? VirtualApplication { get; set; }
}

11
src/OpenIddict.Server.SystemNetHttp/OpenIddictServerSystemNetHttpExtensions.cs

@ -7,6 +7,7 @@
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Options;
using OpenIddict.Core;
using OpenIddict.Server;
using OpenIddict.Server.SystemNetHttp;
@ -36,6 +37,16 @@ public static class OpenIddictServerSystemNetHttpExtensions
// Register the built-in filters used by the default OpenIddict System.Net.Http event handlers.
builder.Services.TryAddSingleton<RequireClientIdMetadataDocumentSupportEnabled>();
// Register the scoped CIMD context used to share metadata document state
// between the fetch handler and the CIMD application manager.
builder.Services.TryAddScoped<OpenIddictServerSystemNetHttpCimdContext>();
// Replace the application manager with the CIMD-aware version that can
// synthesize virtual applications from Client ID Metadata Documents.
builder.Services.Replace(ServiceDescriptor.Scoped(
typeof(OpenIddictApplicationManager<>),
typeof(OpenIddictServerSystemNetHttpApplicationManager<>)));
// Note: TryAddEnumerable() is used here to ensure the initializers are registered only once.
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<
IConfigureOptions<OpenIddictServerOptions>, OpenIddictServerSystemNetHttpConfiguration>());

184
src/OpenIddict.Server.SystemNetHttp/OpenIddictServerSystemNetHttpHandlers.Authentication.cs

@ -6,10 +6,6 @@
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;
@ -26,18 +22,25 @@ public static partial class OpenIddictServerSystemNetHttpHandlers
/// <summary>
/// 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.
/// This handler runs inside the <see cref="ProcessAuthenticationContext"/> pipeline,
/// after <see cref="OpenIddictServerHandlers.ValidateClientId"/> and before
/// <see cref="OpenIddictServerHandlers.ValidateClientType"/>, so that the CIMD
/// context is populated for all endpoint types (authorize, token, etc.).
/// </summary>
public sealed class FetchClientIdMetadataDocument : IOpenIddictServerHandler<ValidateAuthorizationRequestContext>
public sealed class FetchClientIdMetadataDocument : IOpenIddictServerHandler<ProcessAuthenticationContext>
{
private readonly OpenIddictServerSystemNetHttpCimdContext _cimdContext;
private readonly IHttpClientFactory _factory;
private readonly IOptionsMonitor<OpenIddictServerOptions> _serverOptions;
private readonly IOptionsMonitor<OpenIddictServerSystemNetHttpOptions> _httpOptions;
public FetchClientIdMetadataDocument(
OpenIddictServerSystemNetHttpCimdContext cimdContext,
IHttpClientFactory factory,
IOptionsMonitor<OpenIddictServerOptions> serverOptions,
IOptionsMonitor<OpenIddictServerSystemNetHttpOptions> httpOptions)
{
_cimdContext = cimdContext ?? throw new ArgumentNullException(nameof(cimdContext));
_factory = factory ?? throw new ArgumentNullException(nameof(factory));
_serverOptions = serverOptions ?? throw new ArgumentNullException(nameof(serverOptions));
_httpOptions = httpOptions ?? throw new ArgumentNullException(nameof(httpOptions));
@ -47,181 +50,20 @@ public static partial class OpenIddictServerSystemNetHttpHandlers
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictServerHandlerDescriptor Descriptor { get; }
= OpenIddictServerHandlerDescriptor.CreateBuilder<ValidateAuthorizationRequestContext>()
= OpenIddictServerHandlerDescriptor.CreateBuilder<ProcessAuthenticationContext>()
.AddFilter<RequireClientIdMetadataDocumentSupportEnabled>()
.UseScopedHandler<FetchClientIdMetadataDocument>()
// Run after ValidateAuthentication and before RestorePushedAuthorizationRequestParameters.
.SetOrder(OpenIddictServerHandlers.Authentication.ValidateAuthentication.Descriptor.Order + 500)
// Run after ValidateClientId and before ValidateClientType.
.SetOrder(OpenIddictServerHandlers.ValidateClientId.Descriptor.Order + 500)
.SetType(OpenIddictServerHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public async ValueTask HandleAsync(ValidateAuthorizationRequestContext context)
public async ValueTask HandleAsync(ProcessAuthenticationContext 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);
await FetchAndValidateCimdDocumentAsync(context, _cimdContext, _factory, _serverOptions, _httpOptions);
}
}
}

182
src/OpenIddict.Server.SystemNetHttp/OpenIddictServerSystemNetHttpHandlers.cs

@ -26,4 +26,186 @@ public static partial class OpenIddictServerSystemNetHttpHandlers
[
.. Authentication.DefaultHandlers
];
/// <summary>
/// Fetches and validates a CIMD metadata document for the specified client_id.
/// This shared helper is used by both the authentication and exchange pipelines.
/// </summary>
internal static async ValueTask FetchAndValidateCimdDocumentAsync(
BaseValidatingContext context,
OpenIddictServerSystemNetHttpCimdContext cimdContext,
IHttpClientFactory factory,
IOptionsMonitor<OpenIddictServerOptions> serverOptions,
IOptionsMonitor<OpenIddictServerSystemNetHttpOptions> httpOptions)
{
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 httpOpts = httpOptions.CurrentValue;
if (httpOpts.ProductInformation is not null)
{
request.Headers.UserAgent.Add(httpOpts.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 in the scoped CIMD context so that
// the CIMD application manager can synthesize a virtual application from it.
cimdContext.ClientId = clientId;
cimdContext.MetadataDocument = document;
// Also store on transaction properties for backward compatibility.
context.Transaction.Properties[OpenIddictServerSystemNetHttpConstants.Properties.ClientIdMetadataDocument] = document;
context.Logger.LogInformation(
"The CIMD metadata document for client_id '{ClientId}' was successfully fetched and validated.",
clientId);
}
}

117
src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs

@ -1391,13 +1391,6 @@ 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));
@ -1456,74 +1449,6 @@ 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));
@ -1713,12 +1638,6 @@ 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));
@ -1770,12 +1689,6 @@ 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));
@ -1872,12 +1785,6 @@ 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));
@ -1954,12 +1861,6 @@ 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));
@ -2022,12 +1923,6 @@ 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));
@ -2082,12 +1977,6 @@ 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.
@ -2156,12 +2045,6 @@ 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.

5
src/OpenIddict.Server/OpenIddictServerHandlers.Protection.cs

@ -1438,10 +1438,7 @@ public static partial class OpenIddictServerHandlers
};
// If the client application is known, associate it with the token.
// 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))
if (!string.IsNullOrEmpty(context.ClientId))
{
var application = await _applicationManager.FindByClientIdAsync(context.ClientId) ??
throw new InvalidOperationException(SR.GetResourceString(SR.ID0017));

64
src/OpenIddict.Server/OpenIddictServerHandlers.cs

@ -1117,25 +1117,6 @@ 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));
@ -3245,10 +3226,7 @@ public static partial class OpenIddictServerHandlers
descriptor.Scopes.UnionWith(context.Principal.GetScopes());
// If the client application is known, associate it to the authorization.
// 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))
if (!string.IsNullOrEmpty(context.Request.ClientId))
{
var application = await _applicationManager.FindByClientIdAsync(context.Request.ClientId) ??
throw new InvalidOperationException(SR.GetResourceString(SR.ID0017));
@ -3381,10 +3359,7 @@ 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.
// 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 (lifetime is null && !context.Options.EnableDegradedMode && !string.IsNullOrEmpty(context.ClientId))
{
if (_applicationManager is null)
{
@ -3511,10 +3486,7 @@ 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.
// 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 (lifetime is null && !context.Options.EnableDegradedMode && !string.IsNullOrEmpty(context.ClientId))
{
if (_applicationManager is null)
{
@ -3643,10 +3615,7 @@ 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.
// 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 (lifetime is null && !context.Options.EnableDegradedMode && !string.IsNullOrEmpty(context.ClientId))
{
if (_applicationManager is null)
{
@ -3890,10 +3859,7 @@ public static partial class OpenIddictServerHandlers
};
// If the client to which the token is returned is known, use the attached setting if available.
// 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 (lifetime is null && !context.Options.EnableDegradedMode && !string.IsNullOrEmpty(context.ClientId))
{
if (_applicationManager is null)
{
@ -4034,10 +4000,7 @@ 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.
// 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 (lifetime is null && !context.Options.EnableDegradedMode && !string.IsNullOrEmpty(context.ClientId))
{
if (_applicationManager is null)
{
@ -4188,10 +4151,7 @@ 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.
// 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 (lifetime is null && !context.Options.EnableDegradedMode && !string.IsNullOrEmpty(context.ClientId))
{
if (_applicationManager is null)
{
@ -4328,10 +4288,7 @@ 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.
// 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 (lifetime is null && !context.Options.EnableDegradedMode && !string.IsNullOrEmpty(context.ClientId))
{
if (_applicationManager is null)
{
@ -4461,10 +4418,7 @@ 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.
// 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 (lifetime is null && !context.Options.EnableDegradedMode && !string.IsNullOrEmpty(context.ClientId))
{
if (_applicationManager is null)
{

Loading…
Cancel
Save