Browse Source

Update the client stack to support standard token revocation

pull/1984/head
Kévin Chalet 2 years ago
parent
commit
4f09c587ab
  1. 21
      gen/OpenIddict.Client.WebIntegration.Generators/OpenIddictClientWebIntegrationGenerator.cs
  2. 5
      sandbox/OpenIddict.Sandbox.AspNetCore.Server/Startup.cs
  3. 1
      sandbox/OpenIddict.Sandbox.AspNetCore.Server/Worker.cs
  4. 44
      sandbox/OpenIddict.Sandbox.Console.Client/InteractiveService.cs
  5. 36
      src/OpenIddict.Abstractions/OpenIddictResources.resx
  6. 10
      src/OpenIddict.Abstractions/Primitives/OpenIddictConfiguration.cs
  7. 133
      src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Revocation.cs
  8. 1
      src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.cs
  9. 27
      src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationProviders.xsd
  10. 148
      src/OpenIddict.Client/OpenIddictClientEvents.Revocation.cs
  11. 127
      src/OpenIddict.Client/OpenIddictClientEvents.cs
  12. 2
      src/OpenIddict.Client/OpenIddictClientExtensions.cs
  13. 34
      src/OpenIddict.Client/OpenIddictClientHandlerFilters.cs
  14. 92
      src/OpenIddict.Client/OpenIddictClientHandlers.Discovery.cs
  15. 125
      src/OpenIddict.Client/OpenIddictClientHandlers.Revocation.cs
  16. 552
      src/OpenIddict.Client/OpenIddictClientHandlers.cs
  17. 71
      src/OpenIddict.Client/OpenIddictClientModels.cs
  18. 249
      src/OpenIddict.Client/OpenIddictClientService.cs

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

@ -935,6 +935,10 @@ public sealed partial class OpenIddictClientWebIntegrationConfiguration
IntrospectionEndpoint = new Uri($""{{ environment.configuration.introspection_endpoint | string.replace '\'' '""' }}"", UriKind.Absolute),
{{~ end ~}}
{{~ if environment.configuration.revocation_endpoint ~}}
RevocationEndpoint = new Uri($""{{ environment.configuration.revocation_endpoint | string.replace '\'' '""' }}"", UriKind.Absolute),
{{~ end ~}}
{{~ if environment.configuration.token_endpoint ~}}
TokenEndpoint = new Uri($""{{ environment.configuration.token_endpoint | string.replace '\'' '""' }}"", UriKind.Absolute),
{{~ end ~}}
@ -992,6 +996,13 @@ public sealed partial class OpenIddictClientWebIntegrationConfiguration
{{~ end ~}}
},
RevocationEndpointAuthMethodsSupported =
{
{{~ for method in environment.configuration.revocation_endpoint_auth_methods_supported ~}}
""{{ method }}"",
{{~ end ~}}
},
TokenEndpointAuthMethodsSupported =
{
{{~ for method in environment.configuration.token_endpoint_auth_methods_supported ~}}
@ -1050,6 +1061,7 @@ public sealed partial class OpenIddictClientWebIntegrationConfiguration
AuthorizationEndpoint = (string?) configuration.Attribute("AuthorizationEndpoint"),
DeviceAuthorizationEndpoint = (string?) configuration.Attribute("DeviceAuthorizationEndpoint"),
IntrospectionEndpoint = (string?) configuration.Attribute("IntrospectionEndpoint"),
RevocationEndpoint = (string?) configuration.Attribute("RevocationEndpoint"),
TokenEndpoint = (string?) configuration.Attribute("TokenEndpoint"),
UserinfoEndpoint = (string?) configuration.Attribute("UserinfoEndpoint"),
@ -1109,6 +1121,15 @@ public sealed partial class OpenIddictClientWebIntegrationConfiguration
_ => [ClientAuthenticationMethods.ClientSecretPost]
},
RevocationEndpointAuthMethodsSupported = configuration.Elements("RevocationEndpointAuthMethod").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 revocation request payload.
_ => [ClientAuthenticationMethods.ClientSecretPost]
},
TokenEndpointAuthMethodsSupported = configuration.Elements("TokenEndpointAuthMethod").ToList() switch
{
{ Count: > 0 } methods => methods.Select(type => (string?) type.Attribute("Value")).ToList(),

5
sandbox/OpenIddict.Sandbox.AspNetCore.Server/Startup.cs

@ -100,12 +100,13 @@ public class Startup
// Register the OpenIddict server components.
.AddServer(options =>
{
// Enable the authorization, device, introspection,
// logout, token, userinfo and verification endpoints.
// Enable the authorization, device, introspection, logout,
// token, revocation, userinfo and verification endpoints.
options.SetAuthorizationEndpointUris("connect/authorize")
.SetDeviceEndpointUris("connect/device")
.SetIntrospectionEndpointUris("connect/introspect")
.SetLogoutEndpointUris("connect/logout")
.SetRevocationEndpointUris("connect/revoke")
.SetTokenEndpointUris("connect/token")
.SetUserinfoEndpointUris("connect/userinfo")
.SetVerificationEndpointUris("connect/verify");

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

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

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

@ -98,6 +98,20 @@ public class InteractiveService : BackgroundService
})).Principal));
}
// If revocation is supported by the server, ask the user if the access token should be revoked.
if (configuration.RevocationEndpoint is not null && await RevokeAccessTokenAsync(stoppingToken))
{
await _service.RevokeTokenAsync(new()
{
CancellationToken = stoppingToken,
ProviderName = provider,
Token = response.AccessToken,
TokenTypeHint = TokenTypeHints.AccessToken
});
AnsiConsole.MarkupLine("[steelblue]Access token revoked.[/]");
}
// 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))
@ -151,6 +165,23 @@ public class InteractiveService : BackgroundService
})).Principal));
}
// If an access token was returned by the authorization server and revocation is
// supported by the server, ask the user if the access token should be revoked.
if (!string.IsNullOrEmpty(response.BackchannelAccessToken) &&
configuration.RevocationEndpoint is not null &&
await RevokeAccessTokenAsync(stoppingToken))
{
await _service.RevokeTokenAsync(new()
{
CancellationToken = stoppingToken,
ProviderName = provider,
Token = response.BackchannelAccessToken,
TokenTypeHint = TokenTypeHints.AccessToken
});
AnsiConsole.MarkupLine("[steelblue]Access token revoked.[/]");
}
// 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))
@ -214,6 +245,19 @@ public class InteractiveService : BackgroundService
return WaitAsync(Task.Run(Prompt, cancellationToken), cancellationToken);
}
static Task<bool> RevokeAccessTokenAsync(CancellationToken cancellationToken)
{
static bool Prompt() => AnsiConsole.Prompt(new ConfirmationPrompt(
"Would you like to revoke 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(

36
src/OpenIddict.Abstractions/OpenIddictResources.resx

@ -1599,6 +1599,36 @@ To apply post-logout redirection responses, create a class implementing 'IOpenId
Error description: {1}
Error URI: {2}</value>
</data>
<data name="ID0429" xml:space="preserve">
<value>An error occurred while revoking a token.
Error: {0}
Error description: {1}
Error URI: {2}</value>
</data>
<data name="ID0430" xml:space="preserve">
<value>An error occurred while preparing the revocation request.
Error: {0}
Error description: {1}
Error URI: {2}</value>
</data>
<data name="ID0431" xml:space="preserve">
<value>An error occurred while sending the revocation request.
Error: {0}
Error description: {1}
Error URI: {2}</value>
</data>
<data name="ID0432" xml:space="preserve">
<value>An error occurred while extracting the revocation response.
Error: {0}
Error description: {1}
Error URI: {2}</value>
</data>
<data name="ID0433" xml:space="preserve">
<value>An error occurred while handling the revocation response.
Error: {0}
Error description: {1}
Error URI: {2}</value>
</data>
<data name="ID2000" xml:space="preserve">
<value>The security token is missing.</value>
</data>
@ -2121,6 +2151,9 @@ To apply post-logout redirection responses, create a class implementing 'IOpenId
<data name="ID2174" xml:space="preserve">
<value>The '{0}' client authentication method is not supported.</value>
</data>
<data name="ID2175" xml:space="preserve">
<value>The revocation request was rejected by the remote server.</value>
</data>
<data name="ID4000" xml:space="preserve">
<value>The '{0}' parameter shouldn't be null or empty at this point.</value>
</data>
@ -2772,6 +2805,9 @@ This may indicate that the hashed entry is corrupted or malformed.</value>
<data name="ID6229" xml:space="preserve">
<value>An error occurred while trying to revoke the tokens associated with the authorization '{Identifier}'.</value>
</data>
<data name="ID6230" xml:space="preserve">
<value>The revocation request was rejected by the remote authorization server: {Response}.</value>
</data>
<data name="ID8000" xml:space="preserve">
<value>https://documentation.openiddict.com/errors/{0}</value>
</data>

10
src/OpenIddict.Abstractions/Primitives/OpenIddictConfiguration.cs

@ -91,6 +91,16 @@ public sealed class OpenIddictConfiguration
/// </summary>
public HashSet<string> ResponseTypesSupported { get; } = new(StringComparer.Ordinal);
/// <summary>
/// Gets or sets the URI of the revocation endpoint.
/// </summary>
public Uri? RevocationEndpoint { get; set; }
/// <summary>
/// Gets the client authentication methods supported by the revocation endpoint.
/// </summary>
public HashSet<string> RevocationEndpointAuthMethodsSupported { get; } = new(StringComparer.Ordinal);
/// <summary>
/// Gets the scopes supported by the server.
/// </summary>

133
src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Revocation.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 Revocation
{
public static ImmutableArray<OpenIddictClientHandlerDescriptor> DefaultHandlers { get; } = [
/*
* Revocation request processing:
*/
CreateHttpClient<PrepareRevocationRequestContext>.Descriptor,
PreparePostHttpRequest<PrepareRevocationRequestContext>.Descriptor,
AttachHttpVersion<PrepareRevocationRequestContext>.Descriptor,
AttachJsonAcceptHeaders<PrepareRevocationRequestContext>.Descriptor,
AttachUserAgentHeader<PrepareRevocationRequestContext>.Descriptor,
AttachFromHeader<PrepareRevocationRequestContext>.Descriptor,
AttachBasicAuthenticationCredentials.Descriptor,
AttachHttpParameters<PrepareRevocationRequestContext>.Descriptor,
SendHttpRequest<ApplyRevocationRequestContext>.Descriptor,
DisposeHttpRequest<ApplyRevocationRequestContext>.Descriptor,
/*
* Revocation response processing:
*/
DecompressResponseContent<ExtractRevocationResponseContext>.Descriptor,
ExtractJsonHttpResponse<ExtractRevocationResponseContext>.Descriptor,
ExtractWwwAuthenticateHeader<ExtractRevocationResponseContext>.Descriptor,
ValidateHttpResponse<ExtractRevocationResponseContext>.Descriptor,
DisposeHttpResponse<ExtractRevocationResponseContext>.Descriptor
];
/// <summary>
/// Contains the logic responsible for attaching the client credentials to the HTTP Authorization header.
/// </summary>
public sealed class AttachBasicAuthenticationCredentials : IOpenIddictClientHandler<PrepareRevocationRequestContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<PrepareRevocationRequestContext>()
.AddFilter<RequireHttpMetadataUri>()
.UseSingletonHandler<AttachBasicAuthenticationCredentials>()
.SetOrder(AttachHttpParameters<PrepareRevocationRequestContext>.Descriptor.Order - 500)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public ValueTask HandleAsync(PrepareRevocationRequestContext 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.RevocationEndpointAuthMethodsSupported 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

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

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

@ -177,6 +177,27 @@
</xs:complexType>
</xs:element>
<xs:element name="RevocationEndpointAuthMethod" minOccurs="0" maxOccurs="10">
<xs:annotation>
<xs:documentation>The revocation endpoint authentication methods supported by the environment.</xs:documentation>
</xs:annotation>
<xs:complexType>
<xs:attribute name="Value" use="required">
<xs:annotation>
<xs:documentation>The revocation 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>
@ -217,6 +238,12 @@
</xs:annotation>
</xs:attribute>
<xs:attribute name="RevocationEndpoint" type="xs:string" use="optional">
<xs:annotation>
<xs:documentation>The revocation 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>

148
src/OpenIddict.Client/OpenIddictClientEvents.Revocation.cs

@ -0,0 +1,148 @@
/*
* 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 revocation endpoint
/// to give the user code a chance to add parameters to the revocation request.
/// </summary>
public sealed class PrepareRevocationRequestContext : BaseExternalContext
{
/// <summary>
/// Creates a new instance of the <see cref="PrepareRevocationRequestContext"/> class.
/// </summary>
public PrepareRevocationRequestContext(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 revocation endpoint.
/// </summary>
public string? Token
{
get => Request.Token;
set => Request.Token = value;
}
/// <summary>
/// Gets or sets the token type sent to the revocation endpoint.
/// </summary>
public string? TokenTypeHint
{
get => Request.TokenTypeHint;
set => Request.TokenTypeHint = value;
}
}
/// <summary>
/// Represents an event called for each request to the revocation endpoint
/// to send the revocation request to the remote authorization server.
/// </summary>
public sealed class ApplyRevocationRequestContext : BaseExternalContext
{
/// <summary>
/// Creates a new instance of the <see cref="ApplyRevocationRequestContext"/> class.
/// </summary>
public ApplyRevocationRequestContext(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 revocation response
/// to extract the response parameters from the server response.
/// </summary>
public sealed class ExtractRevocationResponseContext : BaseExternalContext
{
/// <summary>
/// Creates a new instance of the <see cref="ExtractRevocationResponseContext"/> class.
/// </summary>
public ExtractRevocationResponseContext(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 revocation response.
/// </summary>
public sealed class HandleRevocationResponseContext : BaseExternalContext
{
/// <summary>
/// Creates a new instance of the <see cref="HandleRevocationResponseContext"/> class.
/// </summary>
public HandleRevocationResponseContext(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 revocation endpoint.
/// </summary>
public string? Token { get; set; }
}
}

127
src/OpenIddict.Client/OpenIddictClientEvents.cs

@ -1317,6 +1317,133 @@ public static partial class OpenIddictClientEvents
public OpenIddictResponse? IntrospectionResponse { get; set; }
}
/// <summary>
/// Represents an event called when processing an revocation operation.
/// </summary>
public sealed class ProcessRevocationContext : BaseValidatingContext
{
/// <summary>
/// Creates a new instance of the <see cref="ProcessRevocationContext"/> class.
/// </summary>
public ProcessRevocationContext(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 revocation endpoint, if applicable.
/// </summary>
public Uri? RevocationEndpoint { get; set; }
/// <summary>
/// Gets or sets the client identifier that will be used for the revocation demand.
/// </summary>
public string? ClientId { get; set; }
/// <summary>
/// Gets or sets a boolean indicating whether an revocation request should be sent.
/// </summary>
public bool SendRevocationRequest { 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 revocation endpoint, if applicable.
/// </summary>
public OpenIddictRequest? RevocationRequest { get; set; }
/// <summary>
/// Gets or sets the response returned by the revocation endpoint, if applicable.
/// </summary>
public OpenIddictResponse? RevocationResponse { get; set; }
}
/// <summary>
/// Represents an event called when processing a sign-out response.
/// </summary>

2
src/OpenIddict.Client/OpenIddictClientExtensions.cs

@ -58,6 +58,8 @@ public static class OpenIddictClientExtensions
builder.Services.TryAddSingleton<RequirePostLogoutRedirectionRequest>();
builder.Services.TryAddSingleton<RequireRedirectionRequest>();
builder.Services.TryAddSingleton<RequireRefreshTokenValidated>();
builder.Services.TryAddSingleton<RequireRevocationClientAssertionGenerated>();
builder.Services.TryAddSingleton<RequireRevocationRequest>();
builder.Services.TryAddSingleton<RequireStateTokenPrincipal>();
builder.Services.TryAddSingleton<RequireStateTokenValidated>();
builder.Services.TryAddSingleton<RequireTokenEntryCreated>();

34
src/OpenIddict.Client/OpenIddictClientHandlerFilters.cs

@ -392,6 +392,40 @@ public static class OpenIddictClientHandlerFilters
}
}
/// <summary>
/// Represents a filter that excludes the associated handlers if no revocation client assertion is generated.
/// </summary>
public sealed class RequireRevocationClientAssertionGenerated : IOpenIddictClientHandlerFilter<ProcessRevocationContext>
{
/// <inheritdoc/>
public ValueTask<bool> IsActiveAsync(ProcessRevocationContext 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 revocation request is expected to be sent.
/// </summary>
public sealed class RequireRevocationRequest : IOpenIddictClientHandlerFilter<ProcessRevocationContext>
{
/// <inheritdoc/>
public ValueTask<bool> IsActiveAsync(ProcessRevocationContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
return new(context.SendRevocationRequest);
}
}
/// <summary>
/// Represents a filter that excludes the associated handlers if no state token principal is available.
/// </summary>

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

@ -27,6 +27,7 @@ public static partial class OpenIddictClientHandlers
ExtractDeviceAuthorizationEndpoint.Descriptor,
ExtractIntrospectionEndpoint.Descriptor,
ExtractLogoutEndpoint.Descriptor,
ExtractRevocationEndpoint.Descriptor,
ExtractTokenEndpoint.Descriptor,
ExtractUserinfoEndpoint.Descriptor,
ExtractGrantTypes.Descriptor,
@ -37,6 +38,7 @@ public static partial class OpenIddictClientHandlers
ExtractIssuerParameterRequirement.Descriptor,
ExtractDeviceAuthorizationEndpointClientAuthenticationMethods.Descriptor,
ExtractIntrospectionEndpointClientAuthenticationMethods.Descriptor,
ExtractRevocationEndpointClientAuthenticationMethods.Descriptor,
ExtractTokenEndpointClientAuthenticationMethods.Descriptor,
/*
@ -482,6 +484,49 @@ public static partial class OpenIddictClientHandlers
}
}
/// <summary>
/// Contains the logic responsible for extracting the revocation endpoint URI from the discovery document.
/// </summary>
public sealed class ExtractRevocationEndpoint : IOpenIddictClientHandler<HandleConfigurationResponseContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<HandleConfigurationResponseContext>()
.UseSingletonHandler<ExtractRevocationEndpoint>()
.SetOrder(ExtractLogoutEndpoint.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.RevocationEndpoint];
if (!string.IsNullOrEmpty(endpoint))
{
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out Uri? uri) || !uri.IsWellFormedOriginalString())
{
context.Reject(
error: Errors.ServerError,
description: SR.FormatID2100(Metadata.RevocationEndpoint),
uri: SR.FormatID8000(SR.ID2100));
return default;
}
context.Configuration.RevocationEndpoint = uri;
}
return default;
}
}
/// <summary>
/// Contains the logic responsible for extracting the token endpoint URI from the discovery document.
/// </summary>
@ -493,7 +538,7 @@ public static partial class OpenIddictClientHandlers
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<HandleConfigurationResponseContext>()
.UseSingletonHandler<ExtractTokenEndpoint>()
.SetOrder(ExtractLogoutEndpoint.Descriptor.Order + 1_000)
.SetOrder(ExtractRevocationEndpoint.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
@ -898,6 +943,49 @@ public static partial class OpenIddictClientHandlers
}
}
/// <summary>
/// Contains the logic responsible for extracting the authentication methods
/// supported by the revocation endpoint from the discovery document.
/// </summary>
public sealed class ExtractRevocationEndpointClientAuthenticationMethods : IOpenIddictClientHandler<HandleConfigurationResponseContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<HandleConfigurationResponseContext>()
.UseSingletonHandler<ExtractRevocationEndpointClientAuthenticationMethods>()
.SetOrder(ExtractIntrospectionEndpointClientAuthenticationMethods.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 revocation endpoint, if available.
var methods = context.Response[Metadata.RevocationEndpointAuthMethodsSupported]?.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.RevocationEndpointAuthMethodsSupported.Add(method);
}
}
}
return default;
}
}
/// <summary>
/// Contains the logic responsible for extracting the authentication methods
/// supported by the token endpoint from the discovery document.
@ -910,7 +998,7 @@ public static partial class OpenIddictClientHandlers
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<HandleConfigurationResponseContext>()
.UseSingletonHandler<ExtractTokenEndpointClientAuthenticationMethods>()
.SetOrder(ExtractIntrospectionEndpointClientAuthenticationMethods.Descriptor.Order + 1_000)
.SetOrder(ExtractRevocationEndpointClientAuthenticationMethods.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();

125
src/OpenIddict.Client/OpenIddictClientHandlers.Revocation.cs

@ -0,0 +1,125 @@
/*
* Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
* See https://github.com/openiddict/openiddict-core for more information concerning
* the license and the contributors participating to this project.
*/
using System.Collections.Immutable;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace OpenIddict.Client;
public static partial class OpenIddictClientHandlers
{
public static class Revocation
{
public static ImmutableArray<OpenIddictClientHandlerDescriptor> DefaultHandlers { get; } = [
/*
* Revocation response handling:
*/
ValidateWellKnownParameters.Descriptor,
HandleErrorResponse.Descriptor
];
/// <summary>
/// Contains the logic responsible for validating the well-known parameters contained in the revocation response.
/// </summary>
public sealed class ValidateWellKnownParameters : IOpenIddictClientHandler<HandleRevocationResponseContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<HandleRevocationResponseContext>()
.UseSingletonHandler<ValidateWellKnownParameters>()
.SetOrder(int.MinValue + 100_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public ValueTask HandleAsync(HandleRevocationResponseContext 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,
// Claims that are not in the well-known list can be of any type.
_ => true
};
}
}
/// <summary>
/// Contains the logic responsible for surfacing potential errors from the revocation response.
/// </summary>
public sealed class HandleErrorResponse : IOpenIddictClientHandler<HandleRevocationResponseContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<HandleRevocationResponseContext>()
.UseSingletonHandler<HandleErrorResponse>()
.SetOrder(ValidateWellKnownParameters.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public ValueTask HandleAsync(HandleRevocationResponseContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
if (!string.IsNullOrEmpty(context.Response.Error))
{
context.Logger.LogInformation(SR.GetResourceString(SR.ID6230), context.Response);
context.Reject(
error: context.Response.Error switch
{
Errors.UnauthorizedClient => Errors.UnauthorizedClient,
_ => Errors.ServerError
},
description: SR.GetResourceString(SR.ID2175),
uri: SR.FormatID8000(SR.ID2175));
return default;
}
return default;
}
}
}
}

552
src/OpenIddict.Client/OpenIddictClientHandlers.cs

@ -146,6 +146,21 @@ public static partial class OpenIddictClientHandlers
SendIntrospectionRequest.Descriptor,
MapIntrospectionParametersToWebServicesFederationClaims.Descriptor,
/*
* Revocation processing:
*/
ValidateRevocationDemand.Descriptor,
ResolveClientRegistrationFromRevocationContext.Descriptor,
AttachClientIdToRevocationContext.Descriptor,
ResolveRevocationEndpoint.Descriptor,
EvaluateRevocationRequest.Descriptor,
AttachRevocationRequestParameters.Descriptor,
EvaluateGeneratedRevocationClientAssertion.Descriptor,
PrepareRevocationClientAssertionPrincipal.Descriptor,
GenerateRevocationClientAssertion.Descriptor,
AttachRevocationRequestClientCredentials.Descriptor,
SendRevocationRequest.Descriptor,
/*
* Sign-out processing:
*/
@ -174,6 +189,7 @@ public static partial class OpenIddictClientHandlers
..Exchange.DefaultHandlers,
..Introspection.DefaultHandlers,
..Protection.DefaultHandlers,
..Revocation.DefaultHandlers,
..Session.DefaultHandlers,
..Userinfo.DefaultHandlers
];
@ -6426,6 +6442,542 @@ public static partial class OpenIddictClientHandlers
}
}
/// <summary>
/// Contains the logic responsible for rejecting invalid revocation demands.
/// </summary>
public sealed class ValidateRevocationDemand : IOpenIddictClientHandler<ProcessRevocationContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessRevocationContext>()
.UseSingletonHandler<ValidateRevocationDemand>()
.SetOrder(int.MinValue + 100_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public ValueTask HandleAsync(ProcessRevocationContext 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 revocation demand.
/// </summary>
public sealed class ResolveClientRegistrationFromRevocationContext : IOpenIddictClientHandler<ProcessRevocationContext>
{
private readonly OpenIddictClientService _service;
public ResolveClientRegistrationFromRevocationContext(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<ProcessRevocationContext>()
.UseSingletonHandler<ResolveClientRegistrationFromRevocationContext>()
.SetOrder(ValidateRevocationDemand.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public async ValueTask HandleAsync(ProcessRevocationContext 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 revocation request.
/// </summary>
public sealed class AttachClientIdToRevocationContext : IOpenIddictClientHandler<ProcessRevocationContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessRevocationContext>()
.UseSingletonHandler<AttachClientIdToRevocationContext>()
.SetOrder(ResolveClientRegistrationFromRevocationContext.Descriptor.Order + 1_000)
.Build();
/// <inheritdoc/>
public ValueTask HandleAsync(ProcessRevocationContext 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 revocation endpoint.
/// </summary>
public sealed class ResolveRevocationEndpoint : IOpenIddictClientHandler<ProcessRevocationContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessRevocationContext>()
.UseSingletonHandler<ResolveRevocationEndpoint>()
.SetOrder(AttachClientIdToRevocationContext.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public ValueTask HandleAsync(ProcessRevocationContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
// If the URI of the revocation endpoint wasn't explicitly set
// at this stage, try to extract it from the server configuration.
context.RevocationEndpoint ??= context.Configuration.RevocationEndpoint switch
{
{ IsAbsoluteUri: true } uri when uri.IsWellFormedOriginalString() => uri,
_ => null
};
return default;
}
}
/// <summary>
/// Contains the logic responsible for determining whether an revocation request should be sent.
/// </summary>
public sealed class EvaluateRevocationRequest : IOpenIddictClientHandler<ProcessRevocationContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessRevocationContext>()
.UseSingletonHandler<EvaluateRevocationRequest>()
.SetOrder(ResolveRevocationEndpoint.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public ValueTask HandleAsync(ProcessRevocationContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
context.SendRevocationRequest = true;
return default;
}
}
/// <summary>
/// Contains the logic responsible for attaching the parameters to the revocation request, if applicable.
/// </summary>
public sealed class AttachRevocationRequestParameters : IOpenIddictClientHandler<ProcessRevocationContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessRevocationContext>()
.AddFilter<RequireRevocationRequest>()
.UseSingletonHandler<AttachRevocationRequestParameters>()
.SetOrder(EvaluateRevocationRequest.Descriptor.Order + 1_000)
.Build();
/// <inheritdoc/>
public ValueTask HandleAsync(ProcessRevocationContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
// Attach a new request instance if necessary.
context.RevocationRequest ??= new OpenIddictRequest();
context.RevocationRequest.Token = context.Token;
context.RevocationRequest.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 revocation demand.
/// </summary>
public sealed class EvaluateGeneratedRevocationClientAssertion : IOpenIddictClientHandler<ProcessRevocationContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessRevocationContext>()
.AddFilter<RequireRevocationRequest>()
.UseSingletonHandler<EvaluateGeneratedRevocationClientAssertion>()
.SetOrder(AttachRevocationRequestParameters.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public ValueTask HandleAsync(ProcessRevocationContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
(context.GenerateClientAssertion,
context.IncludeClientAssertion) = context.Registration.SigningCredentials.Count switch
{
// If an revocation 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.RevocationEndpointAuthMethodsSupported.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 PrepareRevocationClientAssertionPrincipal : IOpenIddictClientHandler<ProcessRevocationContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessRevocationContext>()
.AddFilter<RequireRevocationClientAssertionGenerated>()
.UseSingletonHandler<PrepareRevocationClientAssertionPrincipal>()
.SetOrder(EvaluateGeneratedRevocationClientAssertion.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public ValueTask HandleAsync(ProcessRevocationContext 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 revocation operation.
/// </summary>
public sealed class GenerateRevocationClientAssertion : IOpenIddictClientHandler<ProcessRevocationContext>
{
private readonly IOpenIddictClientDispatcher _dispatcher;
public GenerateRevocationClientAssertion(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<ProcessRevocationContext>()
.AddFilter<RequireRevocationClientAssertionGenerated>()
.UseScopedHandler<GenerateRevocationClientAssertion>()
.SetOrder(PrepareRevocationClientAssertionPrincipal.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public async ValueTask HandleAsync(ProcessRevocationContext 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 revocation request, if applicable.
/// </summary>
public sealed class AttachRevocationRequestClientCredentials : IOpenIddictClientHandler<ProcessRevocationContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessRevocationContext>()
.AddFilter<RequireRevocationRequest>()
.UseSingletonHandler<AttachRevocationRequestClientCredentials>()
.SetOrder(GenerateRevocationClientAssertion.Descriptor.Order + 1_000)
.Build();
/// <inheritdoc/>
public ValueTask HandleAsync(ProcessRevocationContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
Debug.Assert(context.RevocationRequest is not null, SR.GetResourceString(SR.ID4008));
// Always attach the client_id to the request, even if an assertion is sent.
context.RevocationRequest.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.RevocationRequest.ClientAssertion = context.ClientAssertion;
context.RevocationRequest.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.RevocationRequest.ClientSecret = context.Registration.ClientSecret;
}
return default;
}
}
/// <summary>
/// Contains the logic responsible for sending the revocation request, if applicable.
/// </summary>
public sealed class SendRevocationRequest : IOpenIddictClientHandler<ProcessRevocationContext>
{
private readonly OpenIddictClientService _service;
public SendRevocationRequest(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<ProcessRevocationContext>()
.AddFilter<RequireRevocationRequest>()
.UseSingletonHandler<SendRevocationRequest>()
.SetOrder(AttachRevocationRequestClientCredentials.Descriptor.Order + 1_000)
.Build();
/// <inheritdoc/>
public async ValueTask HandleAsync(ProcessRevocationContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
Debug.Assert(context.RevocationRequest is not null, SR.GetResourceString(SR.ID4008));
// Ensure the revocation endpoint is present and is a valid absolute URI.
if (context.RevocationEndpoint is not { IsAbsoluteUri: true } ||
!context.RevocationEndpoint.IsWellFormedOriginalString())
{
throw new InvalidOperationException(SR.FormatID0301(Metadata.RevocationEndpoint));
}
try
{
context.RevocationResponse = await _service.SendRevocationRequestAsync(
context.Registration, context.Configuration,
context.RevocationRequest, context.RevocationEndpoint, context.CancellationToken);
}
catch (ProtocolException exception)
{
context.Reject(
error: exception.Error,
description: exception.ErrorDescription,
uri: exception.ErrorUri);
return;
}
}
}
/// <summary>
/// Contains the logic responsible for ensuring that the sign-out demand
/// is compatible with the type of the endpoint that handled the request.

71
src/OpenIddict.Client/OpenIddictClientModels.cs

@ -873,4 +873,75 @@ public static class OpenIddictClientModels
[EditorBrowsable(EditorBrowsableState.Advanced)]
public required ClaimsPrincipal? UserinfoTokenPrincipal { get; init; }
}
/// <summary>
/// Represents an revocation request.
/// </summary>
public sealed record class RevocationRequest
{
/// <summary>
/// Gets or sets the parameters that will be added to the revocation request.
/// </summary>
public Dictionary<string, OpenIddictParameter>? AdditionalRevocationRequestParameters { 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 revocation result.
/// </summary>
public sealed record class RevocationResult
{
/// <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 revocation response.
/// </summary>
public required OpenIddictResponse RevocationResponse { get; init; }
}
}

249
src/OpenIddict.Client/OpenIddictClientService.cs

@ -1018,6 +1018,86 @@ public class OpenIddictClientService
}
}
/// <summary>
/// Revokes the specified token.
/// </summary>
/// <param name="request">The revocation request.</param>
/// <returns>The revocation result.</returns>
public async ValueTask<RevocationResult> RevokeTokenAsync(RevocationRequest 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 ProcessRevocationContext(transaction)
{
CancellationToken = request.CancellationToken,
Issuer = request.Issuer,
ProviderName = request.ProviderName,
RegistrationId = request.RegistrationId,
RevocationRequest = request.AdditionalRevocationRequestParameters
is Dictionary<string, OpenIddictParameter> parameters ? new(parameters) : new(),
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.FormatID0429(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.RevocationResponse is not null, SR.GetResourceString(SR.ID4007));
return new()
{
Properties = context.Properties,
RevocationResponse = context.RevocationResponse
};
}
finally
{
if (scope is IAsyncDisposable disposable)
{
await disposable.DisposeAsync();
}
else
{
scope.Dispose();
}
}
}
/// <summary>
/// Retrieves the OpenID Connect server configuration from the specified uri.
/// </summary>
@ -1680,6 +1760,175 @@ public class OpenIddictClientService
}
}
/// <summary>
/// Sends the revocation 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 extracted from the revocation response.</returns>
internal async ValueTask<OpenIddictResponse> SendRevocationRequestAsync(
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 PrepareRevocationRequestAsync();
request = await ApplyRevocationRequestAsync();
var response = await ExtractRevocationResponseAsync();
return await HandleRevocationResponseAsync();
async ValueTask<OpenIddictRequest> PrepareRevocationRequestAsync()
{
var context = new PrepareRevocationRequestContext(transaction)
{
CancellationToken = cancellationToken,
Configuration = configuration,
Registration = registration,
RemoteUri = uri,
Request = request
};
await dispatcher.DispatchAsync(context);
if (context.IsRejected)
{
throw new ProtocolException(
SR.FormatID0430(context.Error, context.ErrorDescription, context.ErrorUri),
context.Error, context.ErrorDescription, context.ErrorUri);
}
return context.Request;
}
async ValueTask<OpenIddictRequest> ApplyRevocationRequestAsync()
{
var context = new ApplyRevocationRequestContext(transaction)
{
CancellationToken = cancellationToken,
Configuration = configuration,
Registration = registration,
RemoteUri = uri,
Request = request
};
await dispatcher.DispatchAsync(context);
if (context.IsRejected)
{
throw new ProtocolException(
SR.FormatID0431(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> ExtractRevocationResponseAsync()
{
var context = new ExtractRevocationResponseContext(transaction)
{
CancellationToken = cancellationToken,
Configuration = configuration,
Registration = registration,
RemoteUri = uri,
Request = request
};
await dispatcher.DispatchAsync(context);
if (context.IsRejected)
{
throw new ProtocolException(
SR.FormatID0432(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> HandleRevocationResponseAsync()
{
var context = new HandleRevocationResponseContext(transaction)
{
CancellationToken = cancellationToken,
Configuration = configuration,
Registration = registration,
RemoteUri = uri,
Request = request,
Response = response
};
await dispatcher.DispatchAsync(context);
if (context.IsRejected)
{
throw new ProtocolException(
SR.FormatID0433(context.Error, context.ErrorDescription, context.ErrorUri),
context.Error, context.ErrorDescription, context.ErrorUri);
}
return context.Response;
}
}
finally
{
if (scope is IAsyncDisposable disposable)
{
await disposable.DisposeAsync();
}
else
{
scope.Dispose();
}
}
}
/// <summary>
/// Sends the token request and retrieves the corresponding response.
/// </summary>

Loading…
Cancel
Save