Skip to content

Commit

Permalink
Update the client stack to support standard token revocation
Browse files Browse the repository at this point in the history
  • Loading branch information
kevinchalet committed Feb 17, 2024
1 parent 372ecea commit 4f09c58
Show file tree
Hide file tree
Showing 18 changed files with 1,674 additions and 4 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -935,6 +935,10 @@ public static partial void ConfigureProvider(OpenIddictClientRegistration regist
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 ~}}
Expand Down Expand Up @@ -992,6 +996,13 @@ public static partial void ConfigureProvider(OpenIddictClientRegistration regist
{{~ 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 ~}}
Expand Down Expand Up @@ -1050,6 +1061,7 @@ public static partial void ConfigureProvider(OpenIddictClientRegistration regist
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"),

Expand Down Expand Up @@ -1109,6 +1121,15 @@ public static partial void ConfigureProvider(OpenIddictClientRegistration regist
_ => [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(),
Expand Down
5 changes: 3 additions & 2 deletions sandbox/OpenIddict.Sandbox.AspNetCore.Server/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,12 +100,13 @@ public void ConfigureServices(IServiceCollection services)
// 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");
Expand Down
1 change: 1 addition & 0 deletions sandbox/OpenIddict.Sandbox.AspNetCore.Server/Worker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ await manager.CreateAsync(new OpenIddictApplicationDescriptor
Permissions.Endpoints.Authorization,
Permissions.Endpoints.Device,
Permissions.Endpoints.Introspection,
Permissions.Endpoints.Revocation,
Permissions.Endpoints.Token,
Permissions.GrantTypes.AuthorizationCode,
Permissions.GrantTypes.DeviceCode,
Expand Down
44 changes: 44 additions & 0 deletions sandbox/OpenIddict.Sandbox.Console.Client/InteractiveService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,20 @@ [yellow]Please visit [link]{result.VerificationUri}[/] and enter
})).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))
Expand Down Expand Up @@ -151,6 +165,23 @@ await IntrospectAccessTokenAsync(stoppingToken))
})).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))
Expand Down Expand Up @@ -214,6 +245,19 @@ static bool Prompt() => AnsiConsole.Prompt(new ConfirmationPrompt(
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(
Expand Down
36 changes: 36 additions & 0 deletions src/OpenIddict.Abstractions/OpenIddictResources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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>
Expand Down
10 changes: 10 additions & 0 deletions src/OpenIddict.Abstractions/Primitives/OpenIddictConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
@@ -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", "+");
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public static partial class OpenIddictClientSystemNetHttpHandlers
..Discovery.DefaultHandlers,
..Exchange.DefaultHandlers,
..Introspection.DefaultHandlers,
..Revocation.DefaultHandlers,
..Userinfo.DefaultHandlers
];

Expand Down
Loading

0 comments on commit 4f09c58

Please sign in to comment.