Browse Source

Add introspection support in the client stack

pull/1983/head
Kévin Chalet 2 years ago
parent
commit
372eceab62
  1. 4
      Directory.Packages.props
  2. 21
      gen/OpenIddict.Client.WebIntegration.Generators/OpenIddictClientWebIntegrationGenerator.cs
  3. 1
      sandbox/OpenIddict.Sandbox.AspNetCore.Server/Worker.cs
  4. 48
      sandbox/OpenIddict.Sandbox.Console.Client/InteractiveService.cs
  5. 15
      src/OpenIddict.Abstractions/OpenIddictResources.resx
  6. 4
      src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreHandlers.cs
  7. 4
      src/OpenIddict.Client.Owin/OpenIddictClientOwinHandlers.cs
  8. 133
      src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Introspection.cs
  9. 1
      src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.cs
  10. 27
      src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationProviders.xsd
  11. 153
      src/OpenIddict.Client/OpenIddictClientEvents.Introspection.cs
  12. 129
      src/OpenIddict.Client/OpenIddictClientEvents.cs
  13. 2
      src/OpenIddict.Client/OpenIddictClientExtensions.cs
  14. 50
      src/OpenIddict.Client/OpenIddictClientHandlerFilters.cs
  15. 92
      src/OpenIddict.Client/OpenIddictClientHandlers.Discovery.cs
  16. 406
      src/OpenIddict.Client/OpenIddictClientHandlers.Introspection.cs
  17. 619
      src/OpenIddict.Client/OpenIddictClientHandlers.cs
  18. 81
      src/OpenIddict.Client/OpenIddictClientModels.cs
  19. 270
      src/OpenIddict.Client/OpenIddictClientService.cs
  20. 2
      src/OpenIddict.Server/OpenIddictServerEvents.cs
  21. 2
      src/OpenIddict.Validation/OpenIddictValidationEvents.cs
  22. 16
      src/OpenIddict.Validation/OpenIddictValidationHandlers.Introspection.cs
  23. 2
      src/OpenIddict.Validation/OpenIddictValidationHandlers.cs
  24. 8
      src/OpenIddict.Validation/OpenIddictValidationService.cs

4
Directory.Packages.props

@ -209,7 +209,7 @@
<PackageVersion Include="Microsoft.Web.Infrastructure" Version="2.0.1" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
<PackageVersion Include="Quartz.Extensions.Hosting" Version="3.5.0" />
<PackageVersion Include="Spectre.Console" Version="0.46.0" />
<PackageVersion Include="Spectre.Console" Version="0.48.0" />
<PackageVersion Include="WebGrease" Version="1.6.0" />
<!--
@ -359,7 +359,7 @@
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging.Debug" Version="8.0.0" />
<PackageVersion Include="Quartz.Extensions.Hosting" Version="3.5.0" />
<PackageVersion Include="Spectre.Console" Version="0.46.0" />
<PackageVersion Include="Spectre.Console" Version="0.48.0" />
<!--
Note: OpenIddict uses PolySharp to dynamically generate polyfills for types that are not available on

21
gen/OpenIddict.Client.WebIntegration.Generators/OpenIddictClientWebIntegrationGenerator.cs

@ -931,6 +931,10 @@ public sealed partial class OpenIddictClientWebIntegrationConfiguration
DeviceAuthorizationEndpoint = new Uri($""{{ environment.configuration.device_authorization_endpoint | string.replace '\'' '""' }}"", UriKind.Absolute),
{{~ end ~}}
{{~ if environment.configuration.introspection_endpoint ~}}
IntrospectionEndpoint = new Uri($""{{ environment.configuration.introspection_endpoint | string.replace '\'' '""' }}"", UriKind.Absolute),
{{~ end ~}}
{{~ if environment.configuration.token_endpoint ~}}
TokenEndpoint = new Uri($""{{ environment.configuration.token_endpoint | string.replace '\'' '""' }}"", UriKind.Absolute),
{{~ end ~}}
@ -981,6 +985,13 @@ public sealed partial class OpenIddictClientWebIntegrationConfiguration
{{~ end ~}}
},
IntrospectionEndpointAuthMethodsSupported =
{
{{~ for method in environment.configuration.introspection_endpoint_auth_methods_supported ~}}
""{{ method }}"",
{{~ end ~}}
},
TokenEndpointAuthMethodsSupported =
{
{{~ for method in environment.configuration.token_endpoint_auth_methods_supported ~}}
@ -1038,6 +1049,7 @@ public sealed partial class OpenIddictClientWebIntegrationConfiguration
{
AuthorizationEndpoint = (string?) configuration.Attribute("AuthorizationEndpoint"),
DeviceAuthorizationEndpoint = (string?) configuration.Attribute("DeviceAuthorizationEndpoint"),
IntrospectionEndpoint = (string?) configuration.Attribute("IntrospectionEndpoint"),
TokenEndpoint = (string?) configuration.Attribute("TokenEndpoint"),
UserinfoEndpoint = (string?) configuration.Attribute("UserinfoEndpoint"),
@ -1088,6 +1100,15 @@ public sealed partial class OpenIddictClientWebIntegrationConfiguration
_ => [ClientAuthenticationMethods.ClientSecretPost]
},
IntrospectionEndpointAuthMethodsSupported = configuration.Elements("IntrospectionEndpointAuthMethod").ToList() switch
{
{ Count: > 0 } methods => methods.Select(type => (string?) type.Attribute("Value")).ToList(),
// If no explicit client authentication method was set, assume the provider only
// supports flowing the client credentials as part of the introspection request payload.
_ => [ClientAuthenticationMethods.ClientSecretPost]
},
TokenEndpointAuthMethodsSupported = configuration.Elements("TokenEndpointAuthMethod").ToList() switch
{
{ Count: > 0 } methods => methods.Select(type => (string?) type.Attribute("Value")).ToList(),

1
sandbox/OpenIddict.Sandbox.AspNetCore.Server/Worker.cs

@ -69,6 +69,7 @@ public class Worker : IHostedService
{
Permissions.Endpoints.Authorization,
Permissions.Endpoints.Device,
Permissions.Endpoints.Introspection,
Permissions.Endpoints.Token,
Permissions.GrantTypes.AuthorizationCode,
Permissions.GrantTypes.DeviceCode,

48
sandbox/OpenIddict.Sandbox.Console.Client/InteractiveService.cs

@ -85,11 +85,24 @@ public class InteractiveService : BackgroundService
AnsiConsole.MarkupLine("[green]Device authentication successful:[/]");
AnsiConsole.Write(CreateClaimTable(response.Principal));
// If introspection is supported by the server, ask the user if the access token should be introspected.
if (configuration.IntrospectionEndpoint is not null && await IntrospectAccessTokenAsync(stoppingToken))
{
AnsiConsole.MarkupLine("[steelblue]Claims extracted from the token introspection response:[/]");
AnsiConsole.Write(CreateClaimTable((await _service.IntrospectTokenAsync(new()
{
CancellationToken = stoppingToken,
ProviderName = provider,
Token = response.AccessToken,
TokenTypeHint = TokenTypeHints.AccessToken
})).Principal));
}
// If a refresh token was returned by the authorization server, ask the user
// if the access token should be refreshed using the refresh_token grant.
if (!string.IsNullOrEmpty(response.RefreshToken) && await UseRefreshTokenGrantAsync(stoppingToken))
{
AnsiConsole.MarkupLine("[green]Token refreshing successful:[/]");
AnsiConsole.MarkupLine("[steelblue]Claims extracted from the refreshed identity:[/]");
AnsiConsole.Write(CreateClaimTable((await _service.AuthenticateWithRefreshTokenAsync(new()
{
CancellationToken = stoppingToken,
@ -122,11 +135,27 @@ public class InteractiveService : BackgroundService
AnsiConsole.MarkupLine("[green]Interactive authentication successful:[/]");
AnsiConsole.Write(CreateClaimTable(response.Principal));
// If an access token was returned by the authorization server and introspection is
// supported by the server, ask the user if the access token should be introspected.
if (!string.IsNullOrEmpty(response.BackchannelAccessToken) &&
configuration.IntrospectionEndpoint is not null &&
await IntrospectAccessTokenAsync(stoppingToken))
{
AnsiConsole.MarkupLine("[steelblue]Claims extracted from the token introspection response:[/]");
AnsiConsole.Write(CreateClaimTable((await _service.IntrospectTokenAsync(new()
{
CancellationToken = stoppingToken,
ProviderName = provider,
Token = response.BackchannelAccessToken,
TokenTypeHint = TokenTypeHints.AccessToken
})).Principal));
}
// If a refresh token was returned by the authorization server, ask the user
// if the access token should be refreshed using the refresh_token grant.
if (!string.IsNullOrEmpty(response.RefreshToken) && await UseRefreshTokenGrantAsync(stoppingToken))
{
AnsiConsole.MarkupLine("[green]Token refreshing successful:[/]");
AnsiConsole.MarkupLine("[steelblue]Claims extracted from the refreshed identity:[/]");
AnsiConsole.Write(CreateClaimTable((await _service.AuthenticateWithRefreshTokenAsync(new()
{
CancellationToken = stoppingToken,
@ -172,11 +201,25 @@ public class InteractiveService : BackgroundService
return table;
}
static Task<bool> IntrospectAccessTokenAsync(CancellationToken cancellationToken)
{
static bool Prompt() => AnsiConsole.Prompt(new ConfirmationPrompt(
"Would you like to introspect the access token?")
{
Comparer = StringComparer.CurrentCultureIgnoreCase,
DefaultValue = false,
ShowDefaultValue = true
});
return WaitAsync(Task.Run(Prompt, cancellationToken), cancellationToken);
}
static Task<bool> UseDeviceAuthorizationGrantAsync(CancellationToken cancellationToken)
{
static bool Prompt() => AnsiConsole.Prompt(new ConfirmationPrompt(
"Would you like to authenticate using the device authorization grant?")
{
Comparer = StringComparer.CurrentCultureIgnoreCase,
DefaultValue = false,
ShowDefaultValue = true
});
@ -189,6 +232,7 @@ public class InteractiveService : BackgroundService
static bool Prompt() => AnsiConsole.Prompt(new ConfirmationPrompt(
"Would you like to refresh the user authentication using the refresh token grant?")
{
Comparer = StringComparer.CurrentCultureIgnoreCase,
DefaultValue = false,
ShowDefaultValue = true
});

15
src/OpenIddict.Abstractions/OpenIddictResources.resx

@ -1173,7 +1173,7 @@ To apply redirection responses, create a class implementing 'IOpenIddictClientHa
<value>No client registration was found in the client options. To add a registration, use 'services.AddOpenIddict().AddClient().AddRegistration()'.</value>
</data>
<data name="ID0305" xml:space="preserve">
<value>No client registration information was specified in the challenge properties. When multiple clients are registered, an issuer, a provider name or a client registration identifier must be specified in the challenge properties.</value>
<value>No client registration information was specified. When multiple clients are registered, an issuer, a provider name or a client registration identifier must be specified in the challenge properties.</value>
</data>
<data name="ID0306" xml:space="preserve">
<value>The specified issuer is not a valid or absolute URI.</value>
@ -1311,15 +1311,17 @@ Alternatively, you can disable the token storage feature by calling 'services.Ad
</data>
<data name="ID0341" xml:space="preserve">
<value>No client registration information was specified in the sign-out properties. When multiple clients are registered, an issuer, a provider name or a client registration identifier must be specified in the sign-out properties.</value>
<comment>This resource is no longer used and will be removed in a future version.</comment>
</data>
<data name="ID0342" xml:space="preserve">
<value>The same issuer cannot be used in multiple client registrations.</value>
</data>
<data name="ID0343" xml:space="preserve">
<value>The request forgery protection claim cannot be resolved from the challenge context.</value>
<value>The request forgery protection claim cannot be resolved from the context.</value>
</data>
<data name="ID0344" xml:space="preserve">
<value>The request forgery protection claim cannot be resolved from the sign-out context.</value>
<comment>This resource is no longer used and will be removed in a future version.</comment>
</data>
<data name="ID0345" xml:space="preserve">
<value>The product name cannot be null or empty.</value>
@ -1343,10 +1345,11 @@ Alternatively, you can disable the token storage feature by calling 'services.Ad
<value>The '{0}' instance returned by CryptoConfig.CreateFromName() is not suitable for the requested operation. When registering a custom implementation of a cryptographic algorithm, make sure it inherits from the correct base type and uses the correct name (e.g "OpenIddict RSA Cryptographic Provider" implementations must derive from System.Security.Cryptography.RSA).</value>
</data>
<data name="ID0352" xml:space="preserve">
<value>The nonce cannot be resolved from the challenge context.</value>
<value>The nonce cannot be resolved from the context.</value>
</data>
<data name="ID0353" xml:space="preserve">
<value>The nonce cannot be resolved from the sign-out context.</value>
<comment>This resource is no longer used and will be removed in a future version.</comment>
</data>
<data name="ID0354" xml:space="preserve">
<value>The nonce cannot be resolved from the state token.</value>
@ -1590,6 +1593,12 @@ To apply post-logout redirection responses, create a class implementing 'IOpenId
<data name="ID0427" xml:space="preserve">
<value>The Amazon integration requires sending the user code to the token endpoint when using the device authorization code grant. For that, attach a ".user_code" authentication property containing the user code returned by the device authorization endpoint.</value>
</data>
<data name="ID0428" xml:space="preserve">
<value>An error occurred while introspecting a token.
Error: {0}
Error description: {1}
Error URI: {2}</value>
</data>
<data name="ID2000" xml:space="preserve">
<value>The security token is missing.</value>
</data>

4
src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreHandlers.cs

@ -965,12 +965,12 @@ public static partial class OpenIddictClientAspNetCoreHandlers
if (string.IsNullOrEmpty(context.Nonce))
{
throw new InvalidOperationException(SR.GetResourceString(SR.ID0353));
throw new InvalidOperationException(SR.GetResourceString(SR.ID0352));
}
if (string.IsNullOrEmpty(context.RequestForgeryProtection))
{
throw new InvalidOperationException(SR.GetResourceString(SR.ID0344));
throw new InvalidOperationException(SR.GetResourceString(SR.ID0343));
}
Debug.Assert(context.StateTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006));

4
src/OpenIddict.Client.Owin/OpenIddictClientOwinHandlers.cs

@ -1018,12 +1018,12 @@ public static partial class OpenIddictClientOwinHandlers
if (string.IsNullOrEmpty(context.Nonce))
{
throw new InvalidOperationException(SR.GetResourceString(SR.ID0353));
throw new InvalidOperationException(SR.GetResourceString(SR.ID0352));
}
if (string.IsNullOrEmpty(context.RequestForgeryProtection))
{
throw new InvalidOperationException(SR.GetResourceString(SR.ID0344));
throw new InvalidOperationException(SR.GetResourceString(SR.ID0343));
}
Debug.Assert(context.StateTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006));

133
src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Introspection.cs

@ -0,0 +1,133 @@
/*
* 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.Diagnostics;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
namespace OpenIddict.Client.SystemNetHttp;
public static partial class OpenIddictClientSystemNetHttpHandlers
{
public static class Introspection
{
public static ImmutableArray<OpenIddictClientHandlerDescriptor> DefaultHandlers { get; } = [
/*
* Introspection request processing:
*/
CreateHttpClient<PrepareIntrospectionRequestContext>.Descriptor,
PreparePostHttpRequest<PrepareIntrospectionRequestContext>.Descriptor,
AttachHttpVersion<PrepareIntrospectionRequestContext>.Descriptor,
AttachJsonAcceptHeaders<PrepareIntrospectionRequestContext>.Descriptor,
AttachUserAgentHeader<PrepareIntrospectionRequestContext>.Descriptor,
AttachFromHeader<PrepareIntrospectionRequestContext>.Descriptor,
AttachBasicAuthenticationCredentials.Descriptor,
AttachHttpParameters<PrepareIntrospectionRequestContext>.Descriptor,
SendHttpRequest<ApplyIntrospectionRequestContext>.Descriptor,
DisposeHttpRequest<ApplyIntrospectionRequestContext>.Descriptor,
/*
* Introspection response processing:
*/
DecompressResponseContent<ExtractIntrospectionResponseContext>.Descriptor,
ExtractJsonHttpResponse<ExtractIntrospectionResponseContext>.Descriptor,
ExtractWwwAuthenticateHeader<ExtractIntrospectionResponseContext>.Descriptor,
ValidateHttpResponse<ExtractIntrospectionResponseContext>.Descriptor,
DisposeHttpResponse<ExtractIntrospectionResponseContext>.Descriptor
];
/// <summary>
/// Contains the logic responsible for attaching the client credentials to the HTTP Authorization header.
/// </summary>
public sealed class AttachBasicAuthenticationCredentials : IOpenIddictClientHandler<PrepareIntrospectionRequestContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<PrepareIntrospectionRequestContext>()
.AddFilter<RequireHttpMetadataUri>()
.UseSingletonHandler<AttachBasicAuthenticationCredentials>()
.SetOrder(AttachHttpParameters<PrepareIntrospectionRequestContext>.Descriptor.Order - 500)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public ValueTask HandleAsync(PrepareIntrospectionRequestContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
Debug.Assert(context.Request is not null, SR.GetResourceString(SR.ID4008));
// This handler only applies to System.Net.Http requests. If the HTTP request cannot be resolved,
// this may indicate that the request was incorrectly processed by another client stack.
var request = context.Transaction.GetHttpRequestMessage() ??
throw new InvalidOperationException(SR.GetResourceString(SR.ID0173));
// The OAuth 2.0 specification recommends sending the client credentials using basic authentication.
// However, this authentication method is known to have severe compatibility/interoperability issues:
//
// - While restricted to clients that have been given a secret (i.e confidential clients) by the
// specification, basic authentication is also sometimes required by server implementations for
// public clients that don't have a client secret: in this case, an empty password is used and
// the client identifier is sent alone in the Authorization header (instead of being sent using
// the standard "client_id" parameter present in the request body).
//
// - While the OAuth 2.0 specification requires that the client credentials be formURL-encoded
// before being base64-encoded, many implementations are known to implement a non-standard
// encoding scheme, where neither the client_id nor the client_secret are formURL-encoded.
//
// To guarantee that the OpenIddict implementation can be used with most servers implementions,
// basic authentication is only used when a client secret is present and client_secret_post is
// always preferred when it's explicitly listed as a supported client authentication method.
// If client_secret_post is not listed or if the server returned an empty methods list,
// client_secret_basic is always used, as it MUST be implemented by all OAuth 2.0 servers.
//
// See https://tools.ietf.org/html/rfc8414#section-2
// and https://tools.ietf.org/html/rfc6749#section-2.3.1 for more information.
if (request.Headers.Authorization is null &&
!string.IsNullOrEmpty(context.Request.ClientId) &&
!string.IsNullOrEmpty(context.Request.ClientSecret) &&
UseBasicAuthentication(context.Configuration))
{
// Important: the credentials MUST be formURL-encoded before being base64-encoded.
var credentials = Convert.ToBase64String(Encoding.ASCII.GetBytes(new StringBuilder()
.Append(EscapeDataString(context.Request.ClientId))
.Append(':')
.Append(EscapeDataString(context.Request.ClientSecret))
.ToString()));
// Attach the authorization header containing the client credentials to the HTTP request.
request.Headers.Authorization = new AuthenticationHeaderValue(Schemes.Basic, credentials);
// Remove the client credentials from the request payload to ensure they are not sent twice.
context.Request.ClientId = context.Request.ClientSecret = null;
}
return default;
static bool UseBasicAuthentication(OpenIddictConfiguration configuration)
=> configuration.IntrospectionEndpointAuthMethodsSupported switch
{
// If at least one authentication method was explicit added, only use basic authentication
// if it's supported AND if client_secret_post is not supported or enabled by the server.
{ Count: > 0 } methods => methods.Contains(ClientAuthenticationMethods.ClientSecretBasic) &&
!methods.Contains(ClientAuthenticationMethods.ClientSecretPost),
// Otherwise, if no authentication method was explicit added, assume only basic is supported.
{ Count: _ } => true
};
static string EscapeDataString(string value) => Uri.EscapeDataString(value).Replace("%20", "+");
}
}
}
}

1
src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.cs

@ -25,6 +25,7 @@ public static partial class OpenIddictClientSystemNetHttpHandlers
..Device.DefaultHandlers,
..Discovery.DefaultHandlers,
..Exchange.DefaultHandlers,
..Introspection.DefaultHandlers,
..Userinfo.DefaultHandlers
];

27
src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationProviders.xsd

@ -156,6 +156,27 @@
</xs:complexType>
</xs:element>
<xs:element name="IntrospectionEndpointAuthMethod" minOccurs="0" maxOccurs="10">
<xs:annotation>
<xs:documentation>The introspection endpoint authentication methods supported by the environment.</xs:documentation>
</xs:annotation>
<xs:complexType>
<xs:attribute name="Value" use="required">
<xs:annotation>
<xs:documentation>The introspection endpoint authentication method name (e.g client_secret_basic).</xs:documentation>
</xs:annotation>
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:enumeration value="client_secret_basic" />
<xs:enumeration value="client_secret_post" />
</xs:restriction>
</xs:simpleType>
</xs:attribute>
</xs:complexType>
</xs:element>
<xs:element name="TokenEndpointAuthMethod" minOccurs="0" maxOccurs="10">
<xs:annotation>
<xs:documentation>The token endpoint authentication methods supported by the environment.</xs:documentation>
@ -190,6 +211,12 @@
</xs:annotation>
</xs:attribute>
<xs:attribute name="IntrospectionEndpoint" type="xs:string" use="optional">
<xs:annotation>
<xs:documentation>The introspection endpoint offered by the environment.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="TokenEndpoint" type="xs:string" use="optional">
<xs:annotation>
<xs:documentation>The token endpoint offered by the environment.</xs:documentation>

153
src/OpenIddict.Client/OpenIddictClientEvents.Introspection.cs

@ -0,0 +1,153 @@
/*
* 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.Security.Claims;
namespace OpenIddict.Client;
public static partial class OpenIddictClientEvents
{
/// <summary>
/// Represents an event called for each request to the introspection endpoint
/// to give the user code a chance to add parameters to the introspection request.
/// </summary>
public sealed class PrepareIntrospectionRequestContext : BaseExternalContext
{
/// <summary>
/// Creates a new instance of the <see cref="PrepareIntrospectionRequestContext"/> class.
/// </summary>
public PrepareIntrospectionRequestContext(OpenIddictClientTransaction transaction)
: base(transaction)
{
}
/// <summary>
/// Gets or sets the request.
/// </summary>
public OpenIddictRequest Request
{
get => Transaction.Request!;
set => Transaction.Request = value;
}
/// <summary>
/// Gets or sets the token sent to the introspection endpoint.
/// </summary>
public string? Token
{
get => Request.Token;
set => Request.Token = value;
}
/// <summary>
/// Gets or sets the token type sent to the introspection endpoint.
/// </summary>
public string? TokenTypeHint
{
get => Request.TokenTypeHint;
set => Request.TokenTypeHint = value;
}
}
/// <summary>
/// Represents an event called for each request to the introspection endpoint
/// to send the introspection request to the remote authorization server.
/// </summary>
public sealed class ApplyIntrospectionRequestContext : BaseExternalContext
{
/// <summary>
/// Creates a new instance of the <see cref="ApplyIntrospectionRequestContext"/> class.
/// </summary>
public ApplyIntrospectionRequestContext(OpenIddictClientTransaction transaction)
: base(transaction)
{
}
/// <summary>
/// Gets or sets the request.
/// </summary>
public OpenIddictRequest Request
{
get => Transaction.Request!;
set => Transaction.Request = value;
}
}
/// <summary>
/// Represents an event called for each introspection response
/// to extract the response parameters from the server response.
/// </summary>
public sealed class ExtractIntrospectionResponseContext : BaseExternalContext
{
/// <summary>
/// Creates a new instance of the <see cref="ExtractIntrospectionResponseContext"/> class.
/// </summary>
public ExtractIntrospectionResponseContext(OpenIddictClientTransaction transaction)
: base(transaction)
{
}
/// <summary>
/// Gets or sets the request.
/// </summary>
public OpenIddictRequest Request
{
get => Transaction.Request!;
set => Transaction.Request = value;
}
/// <summary>
/// Gets or sets the response, or <see langword="null"/> if it wasn't extracted yet.
/// </summary>
public OpenIddictResponse? Response
{
get => Transaction.Response;
set => Transaction.Response = value;
}
}
/// <summary>
/// Represents an event called for each introspection response.
/// </summary>
public sealed class HandleIntrospectionResponseContext : BaseExternalContext
{
/// <summary>
/// Creates a new instance of the <see cref="HandleIntrospectionResponseContext"/> class.
/// </summary>
public HandleIntrospectionResponseContext(OpenIddictClientTransaction transaction)
: base(transaction)
{
}
/// <summary>
/// Gets or sets the request.
/// </summary>
public OpenIddictRequest Request
{
get => Transaction.Request!;
set => Transaction.Request = value;
}
/// <summary>
/// Gets or sets the response.
/// </summary>
public OpenIddictResponse Response
{
get => Transaction.Response!;
set => Transaction.Response = value;
}
/// <summary>
/// Gets or sets the token sent to the introspection endpoint.
/// </summary>
public string? Token { get; set; }
/// <summary>
/// Gets or sets the principal containing the claims resolved from the introspection response.
/// </summary>
public ClaimsPrincipal? Principal { get; set; }
}
}

129
src/OpenIddict.Client/OpenIddictClientEvents.cs

@ -874,7 +874,7 @@ public static partial class OpenIddictClientEvents
}
/// <summary>
/// Represents an event called when processing a challenge response.
/// Represents an event called when processing a challenge operation.
/// </summary>
public sealed class ProcessChallengeContext : BaseValidatingTicketContext
{
@ -1190,6 +1190,133 @@ public static partial class OpenIddictClientEvents
public string? UserCode { get; set; }
}
/// <summary>
/// Represents an event called when processing an introspection operation.
/// </summary>
public sealed class ProcessIntrospectionContext : BaseValidatingTicketContext
{
/// <summary>
/// Creates a new instance of the <see cref="ProcessIntrospectionContext"/> class.
/// </summary>
public ProcessIntrospectionContext(OpenIddictClientTransaction transaction)
: base(transaction)
{
}
/// <summary>
/// Gets or sets the request.
/// </summary>
public OpenIddictRequest Request
{
get => Transaction.Request!;
set => Transaction.Request = value;
}
/// <summary>
/// Gets or sets the response.
/// </summary>
public OpenIddictResponse Response
{
get => Transaction.Response!;
set => Transaction.Response = value;
}
/// <summary>
/// Gets or sets the token to introspect.
/// </summary>
public string? Token { get; set; }
/// <summary>
/// Gets or sets the token type of the token to introspect, used as a hint by the remote server.
/// </summary>
public string? TokenTypeHint { get; set; }
/// <summary>
/// Gets the user-defined authentication properties, if available.
/// </summary>
public Dictionary<string, string?> Properties { get; } = new(StringComparer.Ordinal);
/// <summary>
/// Gets or sets the identifier that will be used to resolve the client registration, if applicable.
/// </summary>
public string? RegistrationId { get; set; }
/// <summary>
/// Gets or sets the issuer URI of the provider that will be
/// used to resolve the client registration, if applicable.
/// </summary>
public Uri? Issuer { get; set; }
/// <summary>
/// Gets or sets the name of the provider that will be
/// used to resolve the client registration, if applicable.
/// </summary>
public string? ProviderName { get; set; }
/// <summary>
/// Gets or sets the URI of the introspection endpoint, if applicable.
/// </summary>
public Uri? IntrospectionEndpoint { get; set; }
/// <summary>
/// Gets or sets the client identifier that will be used for the introspection demand.
/// </summary>
public string? ClientId { get; set; }
/// <summary>
/// Gets or sets a boolean indicating whether an introspection request should be sent.
/// </summary>
public bool SendIntrospectionRequest { get; set; }
/// <summary>
/// Gets or sets a boolean indicating whether a client assertion
/// token should be generated (and optionally included in the request).
/// </summary>
/// <remarks>
/// Note: overriding the value of this property is generally not recommended.
/// </remarks>
public bool GenerateClientAssertion { get; set; }
/// <summary>
/// Gets or sets a boolean indicating whether the generated client
/// assertion should be included as part of the request.
/// </summary>
/// <remarks>
/// Note: overriding the value of this property is generally not recommended.
/// </remarks>
public bool IncludeClientAssertion { get; set; }
/// <summary>
/// Gets or sets the generated client assertion, if applicable.
/// The client assertion will only be returned if
/// <see cref="IncludeClientAssertion"/> is set to <see langword="true"/>.
/// </summary>
public string? ClientAssertion { get; set; }
/// <summary>
/// Gets or sets type of the generated client assertion, if applicable.
/// The client assertion type will only be returned if
/// <see cref="IncludeClientAssertion"/> is set to <see langword="true"/>.
/// </summary>
public string? ClientAssertionType { get; set; }
/// <summary>
/// Gets or sets the principal containing the claims that will be
/// used to create the client assertion, if applicable.
/// </summary>
public ClaimsPrincipal? ClientAssertionPrincipal { get; set; }
/// <summary>
/// Gets or sets the request sent to the introspection endpoint, if applicable.
/// </summary>
public OpenIddictRequest? IntrospectionRequest { get; set; }
/// <summary>
/// Gets or sets the response returned by the introspection endpoint, if applicable.
/// </summary>
public OpenIddictResponse? IntrospectionResponse { get; set; }
}
/// <summary>
/// Represents an event called when processing a sign-out response.
/// </summary>

2
src/OpenIddict.Client/OpenIddictClientExtensions.cs

@ -50,6 +50,8 @@ public static class OpenIddictClientExtensions
builder.Services.TryAddSingleton<RequireFrontchannelIdentityTokenValidated>();
builder.Services.TryAddSingleton<RequireFrontchannelIdentityTokenPrincipal>();
builder.Services.TryAddSingleton<RequireInteractiveGrantType>();
builder.Services.TryAddSingleton<RequireIntrospectionClientAssertionGenerated>();
builder.Services.TryAddSingleton<RequireIntrospectionRequest>();
builder.Services.TryAddSingleton<RequireLoginStateTokenGenerated>();
builder.Services.TryAddSingleton<RequireLogoutStateTokenGenerated>();
builder.Services.TryAddSingleton<RequireJsonWebTokenFormat>();

50
src/OpenIddict.Client/OpenIddictClientHandlerFilters.cs

@ -256,6 +256,40 @@ public static class OpenIddictClientHandlerFilters
}
}
/// <summary>
/// Represents a filter that excludes the associated handlers if no introspection client assertion is generated.
/// </summary>
public sealed class RequireIntrospectionClientAssertionGenerated : IOpenIddictClientHandlerFilter<ProcessIntrospectionContext>
{
/// <inheritdoc/>
public ValueTask<bool> IsActiveAsync(ProcessIntrospectionContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
return new(context.GenerateClientAssertion);
}
}
/// <summary>
/// Represents a filter that excludes the associated handlers if no introspection request is expected to be sent.
/// </summary>
public sealed class RequireIntrospectionRequest : IOpenIddictClientHandlerFilter<ProcessIntrospectionContext>
{
/// <inheritdoc/>
public ValueTask<bool> IsActiveAsync(ProcessIntrospectionContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
return new(context.SendIntrospectionRequest);
}
}
/// <summary>
/// Represents a filter that excludes the associated handlers if the selected token format is not JSON Web Token.
/// </summary>
@ -548,9 +582,12 @@ public static class OpenIddictClientHandlerFilters
/// <summary>
/// Represents a filter that excludes the associated handlers if the WS-Federation claim mapping feature was disabled.
/// </summary>
public sealed class RequireWebServicesFederationClaimMappingEnabled : IOpenIddictClientHandlerFilter<ProcessAuthenticationContext>
public sealed class RequireWebServicesFederationClaimMappingEnabled :
IOpenIddictClientHandlerFilter<ProcessAuthenticationContext>,
IOpenIddictClientHandlerFilter<BaseContext>
{
/// <inheritdoc/>
[Obsolete("This method is obsolete and will be removed in a future version.")]
public ValueTask<bool> IsActiveAsync(ProcessAuthenticationContext context)
{
if (context is null)
@ -560,5 +597,16 @@ public static class OpenIddictClientHandlerFilters
return new(!context.Options.DisableWebServicesFederationClaimMapping);
}
/// <inheritdoc/>
ValueTask<bool> IOpenIddictClientHandlerFilter<BaseContext>.IsActiveAsync(BaseContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
return new(!context.Options.DisableWebServicesFederationClaimMapping);
}
}
}

92
src/OpenIddict.Client/OpenIddictClientHandlers.Discovery.cs

@ -25,6 +25,7 @@ public static partial class OpenIddictClientHandlers
ExtractAuthorizationEndpoint.Descriptor,
ExtractCryptographyEndpoint.Descriptor,
ExtractDeviceAuthorizationEndpoint.Descriptor,
ExtractIntrospectionEndpoint.Descriptor,
ExtractLogoutEndpoint.Descriptor,
ExtractTokenEndpoint.Descriptor,
ExtractUserinfoEndpoint.Descriptor,
@ -35,6 +36,7 @@ public static partial class OpenIddictClientHandlers
ExtractScopes.Descriptor,
ExtractIssuerParameterRequirement.Descriptor,
ExtractDeviceAuthorizationEndpointClientAuthenticationMethods.Descriptor,
ExtractIntrospectionEndpointClientAuthenticationMethods.Descriptor,
ExtractTokenEndpointClientAuthenticationMethods.Descriptor,
/*
@ -394,6 +396,49 @@ public static partial class OpenIddictClientHandlers
}
}
/// <summary>
/// Contains the logic responsible for extracting the introspection endpoint URI from the discovery document.
/// </summary>
public sealed class ExtractIntrospectionEndpoint : IOpenIddictClientHandler<HandleConfigurationResponseContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<HandleConfigurationResponseContext>()
.UseSingletonHandler<ExtractIntrospectionEndpoint>()
.SetOrder(ExtractDeviceAuthorizationEndpoint.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public ValueTask HandleAsync(HandleConfigurationResponseContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
var endpoint = (string?) context.Response[Metadata.IntrospectionEndpoint];
if (!string.IsNullOrEmpty(endpoint))
{
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out Uri? uri) || !uri.IsWellFormedOriginalString())
{
context.Reject(
error: Errors.ServerError,
description: SR.FormatID2100(Metadata.IntrospectionEndpoint),
uri: SR.FormatID8000(SR.ID2100));
return default;
}
context.Configuration.IntrospectionEndpoint = uri;
}
return default;
}
}
/// <summary>
/// Contains the logic responsible for extracting the logout endpoint URI from the discovery document.
/// </summary>
@ -405,7 +450,7 @@ public static partial class OpenIddictClientHandlers
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<HandleConfigurationResponseContext>()
.UseSingletonHandler<ExtractLogoutEndpoint>()
.SetOrder(ExtractDeviceAuthorizationEndpoint.Descriptor.Order + 1_000)
.SetOrder(ExtractIntrospectionEndpoint.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
@ -810,6 +855,49 @@ public static partial class OpenIddictClientHandlers
}
}
/// <summary>
/// Contains the logic responsible for extracting the authentication methods
/// supported by the introspection endpoint from the discovery document.
/// </summary>
public sealed class ExtractIntrospectionEndpointClientAuthenticationMethods : IOpenIddictClientHandler<HandleConfigurationResponseContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<HandleConfigurationResponseContext>()
.UseSingletonHandler<ExtractIntrospectionEndpointClientAuthenticationMethods>()
.SetOrder(ExtractDeviceAuthorizationEndpointClientAuthenticationMethods.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public ValueTask HandleAsync(HandleConfigurationResponseContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
// Resolve the client authentication methods supported by the introspection endpoint, if available.
var methods = context.Response[Metadata.IntrospectionEndpointAuthMethodsSupported]?.GetUnnamedParameters();
if (methods is { Count: > 0 })
{
for (var index = 0; index < methods.Count; index++)
{
// Note: custom values are allowed in this case.
var method = (string?) methods[index];
if (!string.IsNullOrEmpty(method))
{
context.Configuration.IntrospectionEndpointAuthMethodsSupported.Add(method);
}
}
}
return default;
}
}
/// <summary>
/// Contains the logic responsible for extracting the authentication methods
/// supported by the token endpoint from the discovery document.
@ -822,7 +910,7 @@ public static partial class OpenIddictClientHandlers
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<HandleConfigurationResponseContext>()
.UseSingletonHandler<ExtractTokenEndpointClientAuthenticationMethods>()
.SetOrder(ExtractDeviceAuthorizationEndpointClientAuthenticationMethods.Descriptor.Order + 1_000)
.SetOrder(ExtractIntrospectionEndpointClientAuthenticationMethods.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();

406
src/OpenIddict.Client/OpenIddictClientHandlers.Introspection.cs

@ -0,0 +1,406 @@
/*
* 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.Diagnostics;
using System.Security.Claims;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace OpenIddict.Client;
public static partial class OpenIddictClientHandlers
{
public static class Introspection
{
public static ImmutableArray<OpenIddictClientHandlerDescriptor> DefaultHandlers { get; } = [
/*
* Introspection response handling:
*/
ValidateWellKnownParameters.Descriptor,
HandleErrorResponse.Descriptor,
HandleInactiveResponse.Descriptor,
ValidateIssuer.Descriptor,
ValidateTokenUsage.Descriptor,
PopulateClaims.Descriptor
];
/// <summary>
/// Contains the logic responsible for validating the well-known parameters contained in the introspection response.
/// </summary>
public sealed class ValidateWellKnownParameters : IOpenIddictClientHandler<HandleIntrospectionResponseContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<HandleIntrospectionResponseContext>()
.UseSingletonHandler<ValidateWellKnownParameters>()
.SetOrder(int.MinValue + 100_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public ValueTask HandleAsync(HandleIntrospectionResponseContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
foreach (var parameter in context.Response.GetParameters())
{
if (!ValidateParameterType(parameter.Key, parameter.Value))
{
context.Reject(
error: Errors.ServerError,
description: SR.FormatID2107(parameter.Key),
uri: SR.FormatID8000(SR.ID2107));
return default;
}
}
return default;
// Note: in the typical case, the response parameters should be deserialized from a
// JSON response and thus natively stored as System.Text.Json.JsonElement instances.
//
// In the rare cases where the underlying value wouldn't be a JsonElement instance
// (e.g when custom parameters are manually added to the response), the static
// conversion operator would take care of converting the underlying value to a
// JsonElement instance using the same value type as the original parameter value.
static bool ValidateParameterType(string name, OpenIddictParameter value) => name switch
{
// Error parameters MUST be formatted as unique strings:
Parameters.Error or Parameters.ErrorDescription or Parameters.ErrorUri
=> ((JsonElement) value).ValueKind is JsonValueKind.String,
// The following claims MUST be formatted as booleans:
Claims.Active => ((JsonElement) value).ValueKind is JsonValueKind.True or JsonValueKind.False,
// The following claims MUST be formatted as unique strings:
Claims.JwtId or Claims.Issuer or Claims.Scope or Claims.TokenUsage
=> ((JsonElement) value).ValueKind is JsonValueKind.String,
// The following claims MUST be formatted as strings or arrays of strings:
//
// Note: empty arrays and arrays that contain a single value are also considered valid.
Claims.Audience => ((JsonElement) value) is JsonElement element &&
element.ValueKind is JsonValueKind.String ||
(element.ValueKind is JsonValueKind.Array && ValidateStringArray(element)),
// The following claims MUST be formatted as numeric dates:
Claims.ExpiresAt or Claims.IssuedAt or Claims.NotBefore
=> (JsonElement) value is { ValueKind: JsonValueKind.Number } element &&
element.TryGetDecimal(out decimal result) && result is >= 0,
// Claims that are not in the well-known list can be of any type.
_ => true
};
static bool ValidateStringArray(JsonElement element)
{
foreach (var item in element.EnumerateArray())
{
if (item.ValueKind is not JsonValueKind.String)
{
return false;
}
}
return true;
}
}
}
/// <summary>
/// Contains the logic responsible for surfacing potential errors from the introspection response.
/// </summary>
public sealed class HandleErrorResponse : IOpenIddictClientHandler<HandleIntrospectionResponseContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<HandleIntrospectionResponseContext>()
.UseSingletonHandler<HandleErrorResponse>()
.SetOrder(ValidateWellKnownParameters.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public ValueTask HandleAsync(HandleIntrospectionResponseContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
// Note: the specification requires returning most errors (e.g invalid token errors)
// as "active: false" responses instead of as proper OAuth 2.0 error responses.
// For more information, see https://datatracker.ietf.org/doc/html/rfc7662#section-2.3.
if (!string.IsNullOrEmpty(context.Response.Error))
{
context.Logger.LogInformation(SR.GetResourceString(SR.ID6205), context.Response);
context.Reject(
error: context.Response.Error switch
{
Errors.UnauthorizedClient => Errors.UnauthorizedClient,
_ => Errors.ServerError
},
description: SR.GetResourceString(SR.ID2146),
uri: SR.FormatID8000(SR.ID2146));
return default;
}
return default;
}
}
/// <summary>
/// Contains the logic responsible for extracting the active: false marker from the response.
/// </summary>
public sealed class HandleInactiveResponse : IOpenIddictClientHandler<HandleIntrospectionResponseContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<HandleIntrospectionResponseContext>()
.UseSingletonHandler<HandleInactiveResponse>()
.SetOrder(HandleErrorResponse.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public ValueTask HandleAsync(HandleIntrospectionResponseContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
// Note: the introspection specification requires that server return "active: false" instead of a proper
// OAuth 2.0 error when the token is invalid, expired, revoked or invalid for any other reason.
// While OpenIddict's server can be tweaked to return a proper error (by removing NormalizeErrorResponse)
// from the enabled handlers, supporting "active: false" is required to ensure total compatibility.
var active = (bool?) context.Response[Parameters.Active];
if (active is null)
{
context.Reject(
error: Errors.ServerError,
description: SR.FormatID2105(Parameters.Active),
uri: SR.FormatID8000(SR.ID2105));
return default;
}
if (active is not true)
{
context.Reject(
error: Errors.InvalidToken,
description: SR.GetResourceString(SR.ID2106),
uri: SR.FormatID8000(SR.ID2106));
return default;
}
return default;
}
}
/// <summary>
/// Contains the logic responsible for extracting the issuer from the introspection response.
/// </summary>
public sealed class ValidateIssuer : IOpenIddictClientHandler<HandleIntrospectionResponseContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<HandleIntrospectionResponseContext>()
.UseSingletonHandler<ValidateIssuer>()
.SetOrder(ValidateWellKnownParameters.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public ValueTask HandleAsync(HandleIntrospectionResponseContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
// The issuer claim is optional. If it's not null or empty, validate it to
// ensure it matches the issuer registered in the server configuration.
var issuer = (string?) context.Response[Claims.Issuer];
if (!string.IsNullOrEmpty(issuer))
{
if (!Uri.TryCreate(issuer, UriKind.Absolute, out Uri? uri))
{
context.Reject(
error: Errors.ServerError,
description: SR.GetResourceString(SR.ID2108),
uri: SR.FormatID8000(SR.ID2108));
return default;
}
// Ensure the issuer matches the expected value.
if (uri != context.Configuration.Issuer)
{
context.Reject(
error: Errors.ServerError,
description: SR.GetResourceString(SR.ID2109),
uri: SR.FormatID8000(SR.ID2109));
return default;
}
}
return default;
}
}
/// <summary>
/// Contains the logic responsible for extracting and validating the token usage from the introspection response.
/// </summary>
public sealed class ValidateTokenUsage : IOpenIddictClientHandler<HandleIntrospectionResponseContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<HandleIntrospectionResponseContext>()
.UseSingletonHandler<ValidateTokenUsage>()
.SetOrder(ValidateIssuer.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public ValueTask HandleAsync(HandleIntrospectionResponseContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
// OpenIddict-based authorization servers always return the actual token type using
// the special "token_usage" claim, that helps resource servers determine whether the
// introspected token is of the expected type and prevent token substitution attacks.
// In this handler, the "token_usage" is verified to ensure it corresponds to a supported
// value so that the component that triggered the introspection request can determine
// whether the returned token has an acceptable type depending on the context.
var usage = (string?) context.Response[Claims.TokenUsage];
if (string.IsNullOrEmpty(usage))
{
return default;
}
// Note: by default, OpenIddict only allows access/refresh tokens to be
// introspected but additional types can be added using the events model.
if (usage is not (TokenTypeHints.AccessToken or TokenTypeHints.AuthorizationCode or
TokenTypeHints.DeviceCode or TokenTypeHints.IdToken or
TokenTypeHints.RefreshToken or TokenTypeHints.UserCode))
{
context.Reject(
error: Errors.ServerError,
description: SR.GetResourceString(SR.ID2118),
uri: SR.FormatID8000(SR.ID2118));
return default;
}
return default;
}
}
/// <summary>
/// Contains the logic responsible for extracting the claims from the introspection response.
/// </summary>
public sealed class PopulateClaims : IOpenIddictClientHandler<HandleIntrospectionResponseContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<HandleIntrospectionResponseContext>()
.UseSingletonHandler<PopulateClaims>()
.SetOrder(ValidateTokenUsage.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public ValueTask HandleAsync(HandleIntrospectionResponseContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
Debug.Assert(context.Registration.Issuer is { IsAbsoluteUri: true }, SR.GetResourceString(SR.ID4013));
// Create a new claims-based identity using the same authentication type
// and the name/role claims as the one used by IdentityModel for JWT tokens.
var identity = new ClaimsIdentity(
context.Registration.TokenValidationParameters.AuthenticationType,
context.Registration.TokenValidationParameters.NameClaimType,
context.Registration.TokenValidationParameters.RoleClaimType);
foreach (var parameter in context.Response.GetParameters())
{
// Always exclude null keys as they can't be represented as valid claims.
if (string.IsNullOrEmpty(parameter.Key))
{
continue;
}
// Exclude OpenIddict-specific private claims, that MUST NOT be set based on data returned
// by the remote authorization server (that may or may not be an OpenIddict server).
if (parameter.Key.StartsWith(Claims.Prefixes.Private, StringComparison.OrdinalIgnoreCase))
{
continue;
}
// Ignore all protocol claims that shouldn't be mapped to CLR claims.
if (parameter.Key is Claims.Active or Claims.Issuer or Claims.NotBefore or Claims.TokenType)
{
continue;
}
// Note: in the typical case, the response parameters should be deserialized from a
// JSON response and thus natively stored as System.Text.Json.JsonElement instances.
//
// In the rare cases where the underlying value wouldn't be a JsonElement instance
// (e.g when custom parameters are manually added to the response), the static
// conversion operator would take care of converting the underlying value to a
// JsonElement instance using the same value type as the original parameter value.
switch ((JsonElement) parameter.Value)
{
// Top-level claims represented as arrays are split and mapped to multiple CLR claims
// to match the logic implemented by IdentityModel for JWT token deserialization.
case { ValueKind: JsonValueKind.Array } value:
identity.AddClaims(parameter.Key, value, context.Registration.Issuer.AbsoluteUri);
break;
case { ValueKind: _ } value:
identity.AddClaim(parameter.Key, value, context.Registration.Issuer.AbsoluteUri);
break;
}
}
context.Principal = new ClaimsPrincipal(identity);
return default;
}
}
}
}

619
src/OpenIddict.Client/OpenIddictClientHandlers.cs

@ -130,6 +130,22 @@ public static partial class OpenIddictClientHandlers
ResolveValidatedDeviceAuthorizationTokens.Descriptor,
ValidateRequiredDeviceAuthorizationTokens.Descriptor,
/*
* Introspection processing:
*/
ValidateIntrospectionDemand.Descriptor,
ResolveClientRegistrationFromIntrospectionContext.Descriptor,
AttachClientIdToIntrospectionContext.Descriptor,
ResolveIntrospectionEndpoint.Descriptor,
EvaluateIntrospectionRequest.Descriptor,
AttachIntrospectionRequestParameters.Descriptor,
EvaluateGeneratedIntrospectionClientAssertion.Descriptor,
PrepareIntrospectionClientAssertionPrincipal.Descriptor,
GenerateIntrospectionClientAssertion.Descriptor,
AttachIntrospectionRequestClientCredentials.Descriptor,
SendIntrospectionRequest.Descriptor,
MapIntrospectionParametersToWebServicesFederationClaims.Descriptor,
/*
* Sign-out processing:
*/
@ -156,6 +172,7 @@ public static partial class OpenIddictClientHandlers
..Device.DefaultHandlers,
..Discovery.DefaultHandlers,
..Exchange.DefaultHandlers,
..Introspection.DefaultHandlers,
..Protection.DefaultHandlers,
..Session.DefaultHandlers,
..Userinfo.DefaultHandlers
@ -2631,7 +2648,7 @@ public static partial class OpenIddictClientHandlers
{
context.TokenResponse = await _service.SendTokenRequestAsync(
context.Registration, context.Configuration,
context.TokenRequest, context.TokenEndpoint);
context.TokenRequest, context.TokenEndpoint, context.CancellationToken);
}
catch (ProtocolException exception)
@ -3659,7 +3676,7 @@ public static partial class OpenIddictClientHandlers
(context.UserinfoResponse, (context.UserinfoTokenPrincipal, context.UserinfoToken)) =
await _service.SendUserinfoRequestAsync(
context.Registration, context.Configuration,
context.UserinfoRequest, context.UserinfoEndpoint);
context.UserinfoRequest, context.UserinfoEndpoint, context.CancellationToken);
}
catch (ProtocolException exception)
@ -5671,7 +5688,8 @@ public static partial class OpenIddictClientHandlers
{
context.DeviceAuthorizationResponse = await _service.SendDeviceAuthorizationRequestAsync(
context.Registration, context.Configuration,
context.DeviceAuthorizationRequest, context.DeviceAuthorizationEndpoint);
context.DeviceAuthorizationRequest, context.DeviceAuthorizationEndpoint,
context.CancellationToken);
}
catch (ProtocolException exception)
@ -5817,6 +5835,597 @@ public static partial class OpenIddictClientHandlers
}
}
/// <summary>
/// Contains the logic responsible for rejecting invalid introspection demands.
/// </summary>
public sealed class ValidateIntrospectionDemand : IOpenIddictClientHandler<ProcessIntrospectionContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessIntrospectionContext>()
.UseSingletonHandler<ValidateIntrospectionDemand>()
.SetOrder(int.MinValue + 100_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public ValueTask HandleAsync(ProcessIntrospectionContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
if (context.Registration is null && string.IsNullOrEmpty(context.RegistrationId) &&
context.Issuer is null && string.IsNullOrEmpty(context.ProviderName) &&
context.Options.Registrations.Count is not 1)
{
throw context.Options.Registrations.Count is 0 ?
new InvalidOperationException(SR.GetResourceString(SR.ID0304)) :
new InvalidOperationException(SR.GetResourceString(SR.ID0305));
}
return default;
}
}
/// <summary>
/// Contains the logic responsible for resolving the client registration applicable to the introspection demand.
/// </summary>
public sealed class ResolveClientRegistrationFromIntrospectionContext : IOpenIddictClientHandler<ProcessIntrospectionContext>
{
private readonly OpenIddictClientService _service;
public ResolveClientRegistrationFromIntrospectionContext(OpenIddictClientService service)
=> _service = service ?? throw new ArgumentNullException(nameof(service));
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessIntrospectionContext>()
.UseSingletonHandler<ResolveClientRegistrationFromIntrospectionContext>()
.SetOrder(ValidateIntrospectionDemand.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public async ValueTask HandleAsync(ProcessIntrospectionContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
context.Registration ??= context switch
{
// If specified, resolve the registration using the attached registration identifier.
{ RegistrationId: string identifier } when !string.IsNullOrEmpty(identifier)
=> await _service.GetClientRegistrationByIdAsync(identifier, context.CancellationToken),
// If specified, resolve the registration using the attached issuer URI.
{ Issuer: Uri uri } => await _service.GetClientRegistrationByIssuerAsync(uri, context.CancellationToken),
// If specified, resolve the registration using the attached provider name.
{ ProviderName: string name } when !string.IsNullOrEmpty(name)
=> await _service.GetClientRegistrationByProviderNameAsync(name, context.CancellationToken),
// Otherwise, default to the unique registration available, if possible.
{ Options.Registrations: [OpenIddictClientRegistration registration] } => registration,
// If no registration was added or multiple registrations are present, throw an exception.
{ Options.Registrations: [] } => throw new InvalidOperationException(SR.GetResourceString(SR.ID0304)),
{ Options.Registrations: _ } => throw new InvalidOperationException(SR.GetResourceString(SR.ID0305))
};
if (!string.IsNullOrEmpty(context.RegistrationId) &&
!string.Equals(context.RegistrationId, context.Registration.RegistrationId, StringComparison.Ordinal))
{
throw new InvalidOperationException(SR.GetResourceString(SR.ID0348));
}
if (!string.IsNullOrEmpty(context.ProviderName) &&
!string.Equals(context.ProviderName, context.Registration.ProviderName, StringComparison.Ordinal))
{
throw new InvalidOperationException(SR.GetResourceString(SR.ID0349));
}
if (context.Issuer is not null && context.Issuer != context.Registration.Issuer)
{
throw new InvalidOperationException(SR.GetResourceString(SR.ID0408));
}
// Resolve and attach the server configuration to the context if none has been set already.
if (context.Configuration is null)
{
if (context.Registration.ConfigurationManager is null)
{
throw new InvalidOperationException(SR.GetResourceString(SR.ID0422));
}
try
{
context.Configuration = await context.Registration.ConfigurationManager
.GetConfigurationAsync(context.CancellationToken)
.WaitAsync(context.CancellationToken) ??
throw new InvalidOperationException(SR.GetResourceString(SR.ID0140));
}
catch (Exception exception) when (!OpenIddictHelpers.IsFatal(exception) &&
exception is not OperationCanceledException)
{
context.Logger.LogError(exception, SR.GetResourceString(SR.ID6219));
context.Reject(
error: Errors.ServerError,
description: SR.GetResourceString(SR.ID2170),
uri: SR.FormatID8000(SR.ID2170));
return;
}
}
}
}
/// <summary>
/// Contains the logic responsible for attaching the client identifier to the introspection request.
/// </summary>
public sealed class AttachClientIdToIntrospectionContext : IOpenIddictClientHandler<ProcessIntrospectionContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessIntrospectionContext>()
.UseSingletonHandler<AttachClientIdToIntrospectionContext>()
.SetOrder(ResolveClientRegistrationFromIntrospectionContext.Descriptor.Order + 1_000)
.Build();
/// <inheritdoc/>
public ValueTask HandleAsync(ProcessIntrospectionContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
context.ClientId ??= context.Registration.ClientId;
return default;
}
}
/// <summary>
/// Contains the logic responsible for resolving the URI of the introspection endpoint.
/// </summary>
public sealed class ResolveIntrospectionEndpoint : IOpenIddictClientHandler<ProcessIntrospectionContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessIntrospectionContext>()
.UseSingletonHandler<ResolveIntrospectionEndpoint>()
.SetOrder(AttachClientIdToIntrospectionContext.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public ValueTask HandleAsync(ProcessIntrospectionContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
// If the URI of the introspection endpoint wasn't explicitly set
// at this stage, try to extract it from the server configuration.
context.IntrospectionEndpoint ??= context.Configuration.IntrospectionEndpoint switch
{
{ IsAbsoluteUri: true } uri when uri.IsWellFormedOriginalString() => uri,
_ => null
};
return default;
}
}
/// <summary>
/// Contains the logic responsible for determining whether an introspection request should be sent.
/// </summary>
public sealed class EvaluateIntrospectionRequest : IOpenIddictClientHandler<ProcessIntrospectionContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessIntrospectionContext>()
.UseSingletonHandler<EvaluateIntrospectionRequest>()
.SetOrder(ResolveIntrospectionEndpoint.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public ValueTask HandleAsync(ProcessIntrospectionContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
context.SendIntrospectionRequest = true;
return default;
}
}
/// <summary>
/// Contains the logic responsible for attaching the parameters to the introspection request, if applicable.
/// </summary>
public sealed class AttachIntrospectionRequestParameters : IOpenIddictClientHandler<ProcessIntrospectionContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessIntrospectionContext>()
.AddFilter<RequireIntrospectionRequest>()
.UseSingletonHandler<AttachIntrospectionRequestParameters>()
.SetOrder(EvaluateIntrospectionRequest.Descriptor.Order + 1_000)
.Build();
/// <inheritdoc/>
public ValueTask HandleAsync(ProcessIntrospectionContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
// Attach a new request instance if necessary.
context.IntrospectionRequest ??= new OpenIddictRequest();
context.IntrospectionRequest.Token = context.Token;
context.IntrospectionRequest.TokenTypeHint = context.TokenTypeHint;
return default;
}
}
/// <summary>
/// Contains the logic responsible for selecting the token types that should
/// be generated and optionally sent as part of the introspection demand.
/// </summary>
public sealed class EvaluateGeneratedIntrospectionClientAssertion : IOpenIddictClientHandler<ProcessIntrospectionContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessIntrospectionContext>()
.AddFilter<RequireIntrospectionRequest>()
.UseSingletonHandler<EvaluateGeneratedIntrospectionClientAssertion>()
.SetOrder(AttachIntrospectionRequestParameters.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public ValueTask HandleAsync(ProcessIntrospectionContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
(context.GenerateClientAssertion,
context.IncludeClientAssertion) = context.Registration.SigningCredentials.Count switch
{
// If an introspection request is going to be sent and if at least one signing key
// was attached to the client registration, generate and include a client assertion
// token if the configuration indicates the server supports private_key_jwt.
> 0 when context.Configuration.IntrospectionEndpointAuthMethodsSupported.Contains(
ClientAuthenticationMethods.PrivateKeyJwt) => (true, true),
_ => (false, false)
};
return default;
}
}
/// <summary>
/// Contains the logic responsible for preparing and attaching the claims principal
/// used to generate the client assertion, if one is going to be sent.
/// </summary>
public sealed class PrepareIntrospectionClientAssertionPrincipal : IOpenIddictClientHandler<ProcessIntrospectionContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessIntrospectionContext>()
.AddFilter<RequireIntrospectionClientAssertionGenerated>()
.UseSingletonHandler<PrepareIntrospectionClientAssertionPrincipal>()
.SetOrder(EvaluateGeneratedIntrospectionClientAssertion.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public ValueTask HandleAsync(ProcessIntrospectionContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
Debug.Assert(context.Registration.Issuer is { IsAbsoluteUri: true }, SR.GetResourceString(SR.ID4013));
// Create a new principal that will be used to store the client assertion claims.
var principal = new ClaimsPrincipal(new ClaimsIdentity(
authenticationType: TokenValidationParameters.DefaultAuthenticationType,
nameType: Claims.Name,
roleType: Claims.Role));
principal.SetCreationDate(DateTimeOffset.UtcNow);
var lifetime = context.Options.ClientAssertionLifetime;
if (lifetime.HasValue)
{
principal.SetExpirationDate(principal.GetCreationDate() + lifetime.Value);
}
// Use the issuer URI as the audience. Applications that need to
// use a different value can register a custom event handler.
principal.SetAudiences(context.Registration.Issuer.OriginalString);
// Use the client_id as both the subject and the issuer, as required by the specifications.
principal.SetClaim(Claims.Private.Issuer, context.ClientId)
.SetClaim(Claims.Subject, context.ClientId);
// Use a random GUID as the JWT unique identifier.
principal.SetClaim(Claims.JwtId, Guid.NewGuid().ToString());
context.ClientAssertionPrincipal = principal;
return default;
}
}
/// <summary>
/// Contains the logic responsible for generating a client
/// assertion for the current introspection operation.
/// </summary>
public sealed class GenerateIntrospectionClientAssertion : IOpenIddictClientHandler<ProcessIntrospectionContext>
{
private readonly IOpenIddictClientDispatcher _dispatcher;
public GenerateIntrospectionClientAssertion(IOpenIddictClientDispatcher dispatcher)
=> _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessIntrospectionContext>()
.AddFilter<RequireIntrospectionClientAssertionGenerated>()
.UseScopedHandler<GenerateIntrospectionClientAssertion>()
.SetOrder(PrepareIntrospectionClientAssertionPrincipal.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public async ValueTask HandleAsync(ProcessIntrospectionContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
var notification = new GenerateTokenContext(context.Transaction)
{
CreateTokenEntry = false,
IsReferenceToken = false,
PersistTokenPayload = false,
Principal = context.ClientAssertionPrincipal!,
TokenFormat = TokenFormats.Jwt,
TokenType = TokenTypeHints.ClientAssertion
};
await _dispatcher.DispatchAsync(notification);
if (notification.IsRequestHandled)
{
context.HandleRequest();
return;
}
else if (notification.IsRequestSkipped)
{
context.SkipRequest();
return;
}
else if (notification.IsRejected)
{
context.Reject(
error: notification.Error ?? Errors.InvalidRequest,
description: notification.ErrorDescription,
uri: notification.ErrorUri);
return;
}
context.ClientAssertion = notification.Token;
context.ClientAssertionType = notification.TokenFormat switch
{
TokenFormats.Jwt => ClientAssertionTypes.JwtBearer,
TokenFormats.Saml2 => ClientAssertionTypes.Saml2Bearer,
_ => null
};
}
}
/// <summary>
/// Contains the logic responsible for attaching the client credentials to the introspection request, if applicable.
/// </summary>
public sealed class AttachIntrospectionRequestClientCredentials : IOpenIddictClientHandler<ProcessIntrospectionContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessIntrospectionContext>()
.AddFilter<RequireIntrospectionRequest>()
.UseSingletonHandler<AttachIntrospectionRequestClientCredentials>()
.SetOrder(GenerateIntrospectionClientAssertion.Descriptor.Order + 1_000)
.Build();
/// <inheritdoc/>
public ValueTask HandleAsync(ProcessIntrospectionContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
Debug.Assert(context.IntrospectionRequest is not null, SR.GetResourceString(SR.ID4008));
// Always attach the client_id to the request, even if an assertion is sent.
context.IntrospectionRequest.ClientId = context.ClientId;
// Note: client authentication methods are mutually exclusive so the client_assertion
// and client_secret parameters MUST never be sent at the same time. For more information,
// see https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.
if (context.IncludeClientAssertion)
{
context.IntrospectionRequest.ClientAssertion = context.ClientAssertion;
context.IntrospectionRequest.ClientAssertionType = context.ClientAssertionType;
}
// Note: the client_secret may be null at this point (e.g for a public
// client or if a custom authentication method is used by the application).
else
{
context.IntrospectionRequest.ClientSecret = context.Registration.ClientSecret;
}
return default;
}
}
/// <summary>
/// Contains the logic responsible for sending the introspection request, if applicable.
/// </summary>
public sealed class SendIntrospectionRequest : IOpenIddictClientHandler<ProcessIntrospectionContext>
{
private readonly OpenIddictClientService _service;
public SendIntrospectionRequest(OpenIddictClientService service)
=> _service = service ?? throw new ArgumentNullException(nameof(service));
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessIntrospectionContext>()
.AddFilter<RequireIntrospectionRequest>()
.UseSingletonHandler<SendIntrospectionRequest>()
.SetOrder(AttachIntrospectionRequestClientCredentials.Descriptor.Order + 1_000)
.Build();
/// <inheritdoc/>
public async ValueTask HandleAsync(ProcessIntrospectionContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
Debug.Assert(context.IntrospectionRequest is not null, SR.GetResourceString(SR.ID4008));
// Ensure the introspection endpoint is present and is a valid absolute URI.
if (context.IntrospectionEndpoint is not { IsAbsoluteUri: true } ||
!context.IntrospectionEndpoint.IsWellFormedOriginalString())
{
throw new InvalidOperationException(SR.FormatID0301(Metadata.IntrospectionEndpoint));
}
try
{
(context.IntrospectionResponse, context.Principal) = await _service.SendIntrospectionRequestAsync(
context.Registration, context.Configuration,
context.IntrospectionRequest, context.IntrospectionEndpoint, context.CancellationToken);
}
catch (ProtocolException exception)
{
context.Reject(
error: exception.Error,
description: exception.ErrorDescription,
uri: exception.ErrorUri);
return;
}
context.Logger.LogTrace(SR.GetResourceString(SR.ID6154), context.Token, context.Principal.Claims);
}
}
/// <summary>
/// Contains the logic responsible for mapping the introspection parameters
/// to their WS-Federation claim equivalent, if applicable.
/// </summary>
public sealed class MapIntrospectionParametersToWebServicesFederationClaims : IOpenIddictClientHandler<ProcessIntrospectionContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessIntrospectionContext>()
.AddFilter<RequireWebServicesFederationClaimMappingEnabled>()
.UseSingletonHandler<MapIntrospectionParametersToWebServicesFederationClaims>()
.SetOrder(SendIntrospectionRequest.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public ValueTask HandleAsync(ProcessIntrospectionContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
if (context.Options.DisableWebServicesFederationClaimMapping)
{
return default;
}
Debug.Assert(context.Registration.Issuer is { IsAbsoluteUri: true }, SR.GetResourceString(SR.ID4013));
Debug.Assert(context.Principal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006));
// Similarly to the claims mapping feature used during the authentication phase to map standard OpenID Connect
// and provider-specific claims (extracted from either the identity tokens or the userinfo response) to their
// WS-Federation equivalent, this handler is responsible for mapping the standard OAuth 2.0 introspection nodes
// defined by https://datatracker.ietf.org/doc/html/rfc7662#section-2.2 to their WS-Federation equivalent.
var issuer = context.Registration.Issuer.AbsoluteUri;
context.Principal
.SetClaim(ClaimTypes.Name, context.Principal.GetClaim(Claims.Username), issuer)
.SetClaim(ClaimTypes.NameIdentifier, context.Principal.GetClaim(Claims.Subject), issuer);
// Note: while this claim is not exposed by the BCL ClaimTypes class, it is used by both ASP.NET Identity
// for ASP.NET 4.x and the System.Web.WebPages package, that requires it for antiforgery to work correctly.
context.Principal.SetClaim("http://schemas.microsoft.com/accesscontrolservice/2010/07/claims/identityprovider",
context.Principal.GetClaim(Claims.Private.ProviderName));
return default;
}
}
/// <summary>
/// Contains the logic responsible for ensuring that the sign-out demand
/// is compatible with the type of the endpoint that handled the request.
@ -5863,7 +6472,7 @@ public static partial class OpenIddictClientHandlers
{
throw context.Options.Registrations.Count is 0 ?
new InvalidOperationException(SR.GetResourceString(SR.ID0304)) :
new InvalidOperationException(SR.GetResourceString(SR.ID0341));
new InvalidOperationException(SR.GetResourceString(SR.ID0305));
}
if (context.Principal is not { Identity: ClaimsIdentity })
@ -5955,7 +6564,7 @@ public static partial class OpenIddictClientHandlers
// If no registration was added or multiple registrations are present, throw an exception.
{ Options.Registrations: [] } => throw new InvalidOperationException(SR.GetResourceString(SR.ID0304)),
{ Options.Registrations: _ } => throw new InvalidOperationException(SR.GetResourceString(SR.ID0341))
{ Options.Registrations: _ } => throw new InvalidOperationException(SR.GetResourceString(SR.ID0305))
};
if (!string.IsNullOrEmpty(context.RegistrationId) &&

81
src/OpenIddict.Client/OpenIddictClientModels.cs

@ -549,6 +549,87 @@ public static class OpenIddictClientModels
public Uri? VerificationUriComplete { get; init; }
}
/// <summary>
/// Represents an introspection request.
/// </summary>
public sealed record class IntrospectionRequest
{
/// <summary>
/// Gets or sets the parameters that will be added to the introspection request.
/// </summary>
public Dictionary<string, OpenIddictParameter>? AdditionalIntrospectionRequestParameters { get; init; }
/// <summary>
/// Gets or sets the cancellation token that will be
/// used to determine if the operation was aborted.
/// </summary>
public CancellationToken CancellationToken { get; init; }
/// <summary>
/// Gets or sets the application-specific properties that will be added to the context.
/// </summary>
public Dictionary<string, string?>? Properties { get; init; }
/// <summary>
/// Gets or sets the provider name used to resolve the client registration.
/// </summary>
/// <remarks>
/// Note: if multiple client registrations use the same provider name.
/// the <see cref="RegistrationId"/> property must be explicitly set.
/// </remarks>
public string? ProviderName { get; init; }
/// <summary>
/// Gets or sets the unique identifier of the client registration that will be used.
/// </summary>
public string? RegistrationId { get; init; }
/// <summary>
/// Gets the token that will be sent to the authorization server.
/// </summary>
public required string Token { get; init; }
/// <summary>
/// Gets the token type hint that will be sent to the authorization server.
/// </summary>
public string? TokenTypeHint { get; init; }
/// <summary>
/// Gets or sets the issuer used to resolve the client registration.
/// </summary>
/// <remarks>
/// Note: if multiple client registrations point to the same issuer,
/// the <see cref="RegistrationId"/> property must be explicitly set.
/// </remarks>
public Uri? Issuer { get; init; }
}
/// <summary>
/// Represents an introspection result.
/// </summary>
public sealed record class IntrospectionResult
{
/// <summary>
/// Gets or sets a merged principal containing all the claims
/// extracted from the identity token and userinfo token principals.
/// </summary>
/// <remarks>
/// Note: in most cases, an empty principal will be returned, unless the authorization server
/// supports returning a non-standard identity token for the client credentials grant.
/// </remarks>
public required ClaimsPrincipal Principal { get; init; }
/// <summary>
/// Gets or sets the application-specific properties that were present in the context.
/// </summary>
public required Dictionary<string, string?> Properties { get; init; }
/// <summary>
/// Gets or sets the introspection response.
/// </summary>
public required OpenIddictResponse IntrospectionResponse { get; init; }
}
/// <summary>
/// Represents a resource owner password credentials authentication request.
/// </summary>

270
src/OpenIddict.Client/OpenIddictClientService.cs

@ -937,6 +937,87 @@ public class OpenIddictClientService
}
}
/// <summary>
/// Introspects the specified token.
/// </summary>
/// <param name="request">The introspection request.</param>
/// <returns>The introspection result.</returns>
public async ValueTask<IntrospectionResult> IntrospectTokenAsync(IntrospectionRequest request)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
request.CancellationToken.ThrowIfCancellationRequested();
// Note: this service is registered as a singleton service. As such, it cannot
// directly depend on scoped services like the validation provider. To work around
// this limitation, a scope is manually created for each method to this service.
var scope = _provider.CreateScope();
// Note: a try/finally block is deliberately used here to ensure the service scope
// can be disposed of asynchronously if it implements IAsyncDisposable.
try
{
var dispatcher = scope.ServiceProvider.GetRequiredService<IOpenIddictClientDispatcher>();
var factory = scope.ServiceProvider.GetRequiredService<IOpenIddictClientFactory>();
var transaction = await factory.CreateTransactionAsync();
var context = new ProcessIntrospectionContext(transaction)
{
CancellationToken = request.CancellationToken,
IntrospectionRequest = request.AdditionalIntrospectionRequestParameters
is Dictionary<string, OpenIddictParameter> parameters ? new(parameters) : new(),
Issuer = request.Issuer,
ProviderName = request.ProviderName,
RegistrationId = request.RegistrationId,
Token = request.Token,
TokenTypeHint = request.TokenTypeHint
};
if (request.Properties is { Count: > 0 })
{
foreach (var property in request.Properties)
{
context.Properties[property.Key] = property.Value;
}
}
await dispatcher.DispatchAsync(context);
if (context.IsRejected)
{
throw new ProtocolException(
SR.FormatID0428(context.Error, context.ErrorDescription, context.ErrorUri),
context.Error, context.ErrorDescription, context.ErrorUri);
}
Debug.Assert(context.Registration.Issuer is { IsAbsoluteUri: true }, SR.GetResourceString(SR.ID4013));
Debug.Assert(context.IntrospectionResponse is not null, SR.GetResourceString(SR.ID4007));
return new()
{
IntrospectionResponse = context.IntrospectionResponse,
Principal = context.Principal!,
Properties = context.Properties
};
}
finally
{
if (scope is IAsyncDisposable disposable)
{
await disposable.DisposeAsync();
}
else
{
scope.Dispose();
}
}
}
/// <summary>
/// Retrieves the OpenID Connect server configuration from the specified uri.
/// </summary>
@ -1428,6 +1509,177 @@ public class OpenIddictClientService
}
}
/// <summary>
/// Sends the introspection request and retrieves the corresponding response.
/// </summary>
/// <param name="registration">The client registration.</param>
/// <param name="configuration">The server configuration.</param>
/// <param name="request">The token request.</param>
/// <param name="uri">The uri of the remote token endpoint.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> that can be used to abort the operation.</param>
/// <returns>The response and the principal extracted from the introspection response.</returns>
internal async ValueTask<(OpenIddictResponse, ClaimsPrincipal)> SendIntrospectionRequestAsync(
OpenIddictClientRegistration registration, OpenIddictConfiguration configuration,
OpenIddictRequest request, Uri uri, CancellationToken cancellationToken = default)
{
if (configuration is null)
{
throw new ArgumentNullException(nameof(configuration));
}
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
if (uri is null)
{
throw new ArgumentNullException(nameof(uri));
}
if (!uri.IsAbsoluteUri || !uri.IsWellFormedOriginalString())
{
throw new ArgumentException(SR.GetResourceString(SR.ID0144), nameof(uri));
}
cancellationToken.ThrowIfCancellationRequested();
// Note: this service is registered as a singleton service. As such, it cannot
// directly depend on scoped services like the validation provider. To work around
// this limitation, a scope is manually created for each method to this service.
var scope = _provider.CreateScope();
// Note: a try/finally block is deliberately used here to ensure the service scope
// can be disposed of asynchronously if it implements IAsyncDisposable.
try
{
var dispatcher = scope.ServiceProvider.GetRequiredService<IOpenIddictClientDispatcher>();
var factory = scope.ServiceProvider.GetRequiredService<IOpenIddictClientFactory>();
var transaction = await factory.CreateTransactionAsync();
request = await PrepareIntrospectionRequestAsync();
request = await ApplyIntrospectionRequestAsync();
var response = await ExtractIntrospectionResponseAsync();
return await HandleIntrospectionResponseAsync();
async ValueTask<OpenIddictRequest> PrepareIntrospectionRequestAsync()
{
var context = new PrepareIntrospectionRequestContext(transaction)
{
CancellationToken = cancellationToken,
Configuration = configuration,
Registration = registration,
RemoteUri = uri,
Request = request
};
await dispatcher.DispatchAsync(context);
if (context.IsRejected)
{
throw new ProtocolException(
SR.FormatID0158(context.Error, context.ErrorDescription, context.ErrorUri),
context.Error, context.ErrorDescription, context.ErrorUri);
}
return context.Request;
}
async ValueTask<OpenIddictRequest> ApplyIntrospectionRequestAsync()
{
var context = new ApplyIntrospectionRequestContext(transaction)
{
CancellationToken = cancellationToken,
Configuration = configuration,
Registration = registration,
RemoteUri = uri,
Request = request
};
await dispatcher.DispatchAsync(context);
if (context.IsRejected)
{
throw new ProtocolException(
SR.FormatID0159(context.Error, context.ErrorDescription, context.ErrorUri),
context.Error, context.ErrorDescription, context.ErrorUri);
}
context.Logger.LogInformation(SR.GetResourceString(SR.ID6192), context.RemoteUri, context.Request);
return context.Request;
}
async ValueTask<OpenIddictResponse> ExtractIntrospectionResponseAsync()
{
var context = new ExtractIntrospectionResponseContext(transaction)
{
CancellationToken = cancellationToken,
Configuration = configuration,
Registration = registration,
RemoteUri = uri,
Request = request
};
await dispatcher.DispatchAsync(context);
if (context.IsRejected)
{
throw new ProtocolException(
SR.FormatID0160(context.Error, context.ErrorDescription, context.ErrorUri),
context.Error, context.ErrorDescription, context.ErrorUri);
}
Debug.Assert(context.Response is not null, SR.GetResourceString(SR.ID4007));
context.Logger.LogInformation(SR.GetResourceString(SR.ID6193), context.RemoteUri, context.Response);
return context.Response;
}
async ValueTask<(OpenIddictResponse, ClaimsPrincipal)> HandleIntrospectionResponseAsync()
{
var context = new HandleIntrospectionResponseContext(transaction)
{
CancellationToken = cancellationToken,
Configuration = configuration,
Registration = registration,
RemoteUri = uri,
Request = request,
Response = response
};
await dispatcher.DispatchAsync(context);
if (context.IsRejected)
{
throw new ProtocolException(
SR.FormatID0161(context.Error, context.ErrorDescription, context.ErrorUri),
context.Error, context.ErrorDescription, context.ErrorUri);
}
Debug.Assert(context.Principal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006));
return (context.Response, context.Principal);
}
}
finally
{
if (scope is IAsyncDisposable disposable)
{
await disposable.DisposeAsync();
}
else
{
scope.Dispose();
}
}
}
/// <summary>
/// Sends the token request and retrieves the corresponding response.
/// </summary>
@ -1439,7 +1691,7 @@ public class OpenIddictClientService
/// <returns>The token response.</returns>
internal async ValueTask<OpenIddictResponse> SendTokenRequestAsync(
OpenIddictClientRegistration registration, OpenIddictConfiguration configuration,
OpenIddictRequest request, Uri? uri = null, CancellationToken cancellationToken = default)
OpenIddictRequest request, Uri uri, CancellationToken cancellationToken = default)
{
if (registration is null)
{
@ -1493,9 +1745,9 @@ public class OpenIddictClientService
var context = new PrepareTokenRequestContext(transaction)
{
CancellationToken = cancellationToken,
RemoteUri = uri,
Configuration = configuration,
Registration = registration,
RemoteUri = uri,
Request = request
};
@ -1516,9 +1768,9 @@ public class OpenIddictClientService
var context = new ApplyTokenRequestContext(transaction)
{
CancellationToken = cancellationToken,
RemoteUri = uri,
Configuration = configuration,
Registration = registration,
RemoteUri = uri,
Request = request
};
@ -1541,9 +1793,9 @@ public class OpenIddictClientService
var context = new ExtractTokenResponseContext(transaction)
{
CancellationToken = cancellationToken,
RemoteUri = uri,
Configuration = configuration,
Registration = registration,
RemoteUri = uri,
Request = request
};
@ -1568,9 +1820,9 @@ public class OpenIddictClientService
var context = new HandleTokenResponseContext(transaction)
{
CancellationToken = cancellationToken,
RemoteUri = uri,
Configuration = configuration,
Registration = registration,
RemoteUri = uri,
Request = request,
Response = response
};
@ -1662,8 +1914,8 @@ public class OpenIddictClientService
var context = new PrepareUserinfoRequestContext(transaction)
{
CancellationToken = cancellationToken,
RemoteUri = uri,
Configuration = configuration,
RemoteUri = uri,
Registration = registration,
Request = request
};
@ -1685,8 +1937,8 @@ public class OpenIddictClientService
var context = new ApplyUserinfoRequestContext(transaction)
{
CancellationToken = cancellationToken,
RemoteUri = uri,
Configuration = configuration,
RemoteUri = uri,
Registration = registration,
Request = request
};
@ -1710,8 +1962,8 @@ public class OpenIddictClientService
var context = new ExtractUserinfoResponseContext(transaction)
{
CancellationToken = cancellationToken,
RemoteUri = uri,
Configuration = configuration,
RemoteUri = uri,
Registration = registration,
Request = request
};
@ -1737,9 +1989,9 @@ public class OpenIddictClientService
var context = new HandleUserinfoResponseContext(transaction)
{
CancellationToken = cancellationToken,
RemoteUri = uri,
Configuration = configuration,
Registration = registration,
RemoteUri = uri,
Request = request,
Response = response,
UserinfoToken = token

2
src/OpenIddict.Server/OpenIddictServerEvents.cs

@ -698,7 +698,7 @@ public static partial class OpenIddictServerEvents
}
/// <summary>
/// Represents an event called when processing a challenge response.
/// Represents an event called when processing a challenge operation.
/// </summary>
public sealed class ProcessChallengeContext : BaseValidatingContext
{

2
src/OpenIddict.Validation/OpenIddictValidationEvents.cs

@ -394,7 +394,7 @@ public static partial class OpenIddictValidationEvents
}
/// <summary>
/// Represents an event called when processing a challenge response.
/// Represents an event called when processing a challenge operation.
/// </summary>
public sealed class ProcessChallengeContext : BaseValidatingContext
{

16
src/OpenIddict.Validation/OpenIddictValidationHandlers.Introspection.cs

@ -304,17 +304,11 @@ public static partial class OpenIddictValidationHandlers
return default;
}
if (!(usage switch
{
// Note: by default, OpenIddict only allows access/refresh tokens to be
// introspected but additional types can be added using the events model.
TokenTypeHints.AccessToken or TokenTypeHints.AuthorizationCode or
TokenTypeHints.IdToken or TokenTypeHints.RefreshToken or
TokenTypeHints.UserCode
=> true,
_ => false // Other token usages are not supported.
}))
// Note: by default, OpenIddict only allows access/refresh tokens to be
// introspected but additional types can be added using the events model.
if (usage is not (TokenTypeHints.AccessToken or TokenTypeHints.AuthorizationCode or
TokenTypeHints.DeviceCode or TokenTypeHints.IdToken or
TokenTypeHints.RefreshToken or TokenTypeHints.UserCode))
{
context.Reject(
error: Errors.ServerError,

2
src/OpenIddict.Validation/OpenIddictValidationHandlers.cs

@ -544,7 +544,7 @@ public static partial class OpenIddictValidationHandlers
(context.IntrospectionResponse, context.AccessTokenPrincipal) =
await _service.SendIntrospectionRequestAsync(
context.Configuration, context.IntrospectionRequest,
context.IntrospectionEndpoint);
context.IntrospectionEndpoint, context.CancellationToken);
}
catch (ProtocolException exception)

8
src/OpenIddict.Validation/OpenIddictValidationService.cs

@ -453,7 +453,7 @@ public class OpenIddictValidationService
if (context.IsRejected)
{
throw new ProtocolException(
SR.FormatID0320(context.Error, context.ErrorDescription, context.ErrorUri),
SR.FormatID0158(context.Error, context.ErrorDescription, context.ErrorUri),
context.Error, context.ErrorDescription, context.ErrorUri);
}
@ -475,7 +475,7 @@ public class OpenIddictValidationService
if (context.IsRejected)
{
throw new ProtocolException(
SR.FormatID0321(context.Error, context.ErrorDescription, context.ErrorUri),
SR.FormatID0159(context.Error, context.ErrorDescription, context.ErrorUri),
context.Error, context.ErrorDescription, context.ErrorUri);
}
@ -499,7 +499,7 @@ public class OpenIddictValidationService
if (context.IsRejected)
{
throw new ProtocolException(
SR.FormatID0322(context.Error, context.ErrorDescription, context.ErrorUri),
SR.FormatID0160(context.Error, context.ErrorDescription, context.ErrorUri),
context.Error, context.ErrorDescription, context.ErrorUri);
}
@ -526,7 +526,7 @@ public class OpenIddictValidationService
if (context.IsRejected)
{
throw new ProtocolException(
SR.FormatID0323(context.Error, context.ErrorDescription, context.ErrorUri),
SR.FormatID0161(context.Error, context.ErrorDescription, context.ErrorUri),
context.Error, context.ErrorDescription, context.ErrorUri);
}

Loading…
Cancel
Save