diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Controllers/AuthenticationController.cs b/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Controllers/AuthenticationController.cs index 67ef74a8..cb440579 100644 --- a/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Controllers/AuthenticationController.cs +++ b/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Controllers/AuthenticationController.cs @@ -102,7 +102,7 @@ public class AuthenticationController : Controller // Preserve the access and refresh tokens returned in the token response, if available. { Name: OpenIddictClientAspNetCoreConstants.Tokens.BackchannelAccessToken or - OpenIddictClientAspNetCoreConstants.Tokens.BackchannelRefreshToken + OpenIddictClientAspNetCoreConstants.Tokens.RefreshToken } => true, // Ignore the other tokens. diff --git a/src/OpenIddict.Abstractions/OpenIddictConstants.cs b/src/OpenIddict.Abstractions/OpenIddictConstants.cs index 98b6973d..b078ab77 100644 --- a/src/OpenIddict.Abstractions/OpenIddictConstants.cs +++ b/src/OpenIddict.Abstractions/OpenIddictConstants.cs @@ -482,6 +482,7 @@ public static class OpenIddictConstants public const string IdToken = "id_token"; public const string RefreshToken = "refresh_token"; public const string StateToken = "state_token"; + public const string UserinfoToken = "userinfo_token"; public const string UserCode = "user_code"; } diff --git a/src/OpenIddict.Abstractions/OpenIddictResources.resx b/src/OpenIddict.Abstractions/OpenIddictResources.resx index 08ee8d03..a38aba6c 100644 --- a/src/OpenIddict.Abstractions/OpenIddictResources.resx +++ b/src/OpenIddict.Abstractions/OpenIddictResources.resx @@ -1578,6 +1578,15 @@ To apply redirection responses, create a class implementing 'IOpenIddictClientHa The specified state token is not valid in this context. + + The '{0}' claim extracted from the specified userinfo response/token is malformed or isn't of the expected type. + + + The mandatory '{0}' claim cannot be found in the specified userinfo response/token. + + + The '{0}' claim returned in the specified userinfo response/token doesn't match the expected value. + The '{0}' parameter shouldn't be null or empty at this point. diff --git a/src/OpenIddict.Abstractions/Primitives/OpenIddictConfiguration.cs b/src/OpenIddict.Abstractions/Primitives/OpenIddictConfiguration.cs index 7f75f244..178184e6 100644 --- a/src/OpenIddict.Abstractions/Primitives/OpenIddictConfiguration.cs +++ b/src/OpenIddict.Abstractions/Primitives/OpenIddictConfiguration.cs @@ -90,4 +90,9 @@ public class OpenIddictConfiguration /// Gets the client authentication methods supported by the token endpoint. /// public HashSet TokenEndpointAuthMethodsSupported { get; } = new(StringComparer.Ordinal); + + /// + /// Gets or sets the address of the userinfo endpoint. + /// + public Uri? UserinfoEndpoint { get; set; } } diff --git a/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreConstants.cs b/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreConstants.cs index 55ed7def..6443512e 100644 --- a/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreConstants.cs +++ b/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreConstants.cs @@ -13,27 +13,29 @@ public static class OpenIddictClientAspNetCoreConstants { public static class Properties { + public const string AuthorizationCodePrincipal = ".authorization_code_principal"; public const string BackchannelAccessTokenPrincipal = ".backchannel_access_token_principal"; public const string BackchannelIdentityTokenPrincipal = ".backchannel_id_token_principal"; - public const string BackchannelRefreshTokenPrincipal = ".backchannel_refresh_token_principal"; public const string FrontchannelAccessTokenPrincipal = ".frontchannel_access_token_principal"; - public const string FrontchannelAuthorizationCodePrincipal = ".frontchannel_authorization_code_principal"; public const string FrontchannelIdentityTokenPrincipal = ".frontchannel_id_token_principal"; - public const string FrontchannelStateTokenPrincipal = ".frontchannel_state_token_principal"; public const string Issuer = ".issuer"; public const string Error = ".error"; public const string ErrorDescription = ".error_description"; public const string ErrorUri = ".error_uri"; + public const string RefreshTokenPrincipal = ".refresh_token_principal"; + public const string StateTokenPrincipal = ".state_token_principal"; + public const string UserinfoTokenPrincipal = ".userinfo_token_principal"; } public static class Tokens { + public const string AuthorizationCode = "authorization_code"; public const string BackchannelAccessToken = "backchannel_access_token"; public const string BackchannelIdentityToken = "backchannel_id_token"; - public const string BackchannelRefreshToken = "backchannel_refresh_token"; public const string FrontchannelAccessToken = "frontchannel_access_token"; - public const string FrontchannelAuthorizationCode = "frontchannel_authorization_code"; public const string FrontchannelIdentityToken = "frontchannel_id_token"; - public const string FrontchannelStateToken = "frontchannel_state_token"; + public const string RefreshToken = "refresh_token"; + public const string StateToken = "state_token"; + public const string UserinfoToken = "userinfo_token"; } } diff --git a/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreHandler.cs b/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreHandler.cs index d9c76139..81d80909 100644 --- a/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreHandler.cs +++ b/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreHandler.cs @@ -8,6 +8,7 @@ using System.Security.Claims; using System.Text.Encodings.Web; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; using static OpenIddict.Client.AspNetCore.OpenIddictClientAspNetCoreConstants; using Properties = OpenIddict.Client.AspNetCore.OpenIddictClientAspNetCoreConstants.Properties; @@ -141,16 +142,12 @@ public class OpenIddictClientAspNetCoreHandler : AuthenticationHandler - context.BackchannelIdentityTokenPrincipal ?? - context.FrontchannelIdentityTokenPrincipal ?? - new ClaimsPrincipal(new ClaimsIdentity()), + // Create a composite principal containing claims resolved from the frontchannel + // and backchannel identity tokens and the userinfo token principal, if available. + OpenIddictClientEndpointType.Redirection => CreatePrincipal( + context.FrontchannelIdentityTokenPrincipal, + context.BackchannelIdentityTokenPrincipal, + context.UserinfoTokenPrincipal), _ => null }; @@ -167,7 +164,7 @@ public class OpenIddictClientAspNetCoreHandler : AuthenticationHandler? tokens = null; @@ -175,6 +172,16 @@ public class OpenIddictClientAspNetCoreHandler : AuthenticationHandler 0 }) @@ -264,8 +257,87 @@ public class OpenIddictClientAspNetCoreHandler : AuthenticationHandler principal?.Identity is ClaimsIdentity { IsAuthenticated: true })) + { + return new ClaimsPrincipal(new ClaimsIdentity()); + } + + // Create a new composite identity containing the claims of all the principals. + var identity = new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType); + + foreach (var principal in principals) + { + // Note: the principal may be null if no value was extracted from the corresponding token. + if (principal is null) + { + continue; + } + + foreach (var claim in principal.Claims) + { + // If a claim with the same type and the same value already exist, skip it. + if (identity.HasClaim(claim.Type, claim.Value)) + { + continue; + } + + identity.AddClaim(claim); + } + } + + return new ClaimsPrincipal(identity); + } } } diff --git a/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreHandlers.cs b/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreHandlers.cs index 322192c4..80930d3c 100644 --- a/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreHandlers.cs +++ b/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreHandlers.cs @@ -363,9 +363,9 @@ public static partial class OpenIddictClientAspNetCoreHandlers public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() .AddFilter() - .AddFilter() + .AddFilter() .UseSingletonHandler() - .SetOrder(ValidateFrontchannelStateToken.Descriptor.Order + 500) + .SetOrder(ValidateStateToken.Descriptor.Order + 500) .SetType(OpenIddictClientHandlerType.BuiltIn) .Build(); @@ -377,7 +377,7 @@ public static partial class OpenIddictClientAspNetCoreHandlers throw new ArgumentNullException(nameof(context)); } - Debug.Assert(context.FrontchannelStateTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); + Debug.Assert(context.StateTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); // This handler only applies to ASP.NET Core requests. If the HTTP context cannot be resolved, // this may indicate that the request was incorrectly processed by another server stack. @@ -390,7 +390,7 @@ public static partial class OpenIddictClientAspNetCoreHandlers // Resolve the request forgery protection from the state token principal. // If the claim cannot be found, this means the protection was disabled // using a custom event handler. In this case, bypass the validation. - var claim = context.FrontchannelStateTokenPrincipal.GetClaim(Claims.RequestForgeryProtection); + var claim = context.StateTokenPrincipal.GetClaim(Claims.RequestForgeryProtection); if (string.IsNullOrEmpty(claim)) { return default; diff --git a/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpConstants.cs b/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpConstants.cs new file mode 100644 index 00000000..5ca792c5 --- /dev/null +++ b/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpConstants.cs @@ -0,0 +1,18 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +namespace OpenIddict.Client.SystemNetHttp; + +/// +/// Exposes common constants used by the OpenIddict System.Net.Http integration. +/// +public static class OpenIddictClientSystemNetHttpConstants +{ + public static class ContentTypes + { + public const string JsonWebToken = "application/jwt"; + } +} diff --git a/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Userinfo.cs b/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Userinfo.cs new file mode 100644 index 00000000..50e100ed --- /dev/null +++ b/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Userinfo.cs @@ -0,0 +1,136 @@ +/* + * 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.Headers; +using static OpenIddict.Client.SystemNetHttp.OpenIddictClientSystemNetHttpConstants; + +namespace OpenIddict.Client.SystemNetHttp; + +public static partial class OpenIddictClientSystemNetHttpHandlers +{ + public static class Userinfo + { + public static ImmutableArray DefaultHandlers { get; } = ImmutableArray.Create( + /* + * Userinfo request processing: + */ + PrepareGetHttpRequest.Descriptor, + AttachBearerAccessToken.Descriptor, + AttachFormParameters.Descriptor, + SendHttpRequest.Descriptor, + DisposeHttpRequest.Descriptor, + + /* + * Userinfo response processing: + */ + ExtractUserinfoHttpResponse.Descriptor, + DisposeHttpResponse.Descriptor); + + /// + /// Contains the logic responsible of attaching the access token to the HTTP Authorization header. + /// + public class AttachBearerAccessToken : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(AttachFormParameters.Descriptor.Order - 1000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(PrepareUserinfoRequestContext 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(); + if (request is null) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0173)); + } + + // Attach the authorization header containing the access token to the HTTP request. + request.Headers.Authorization = new AuthenticationHeaderValue(Schemes.Bearer, context.Request.AccessToken); + + // Remove the access from the request payload to ensure it's not sent twice. + context.Request.AccessToken = null; + + return default; + } + } + + /// + /// Contains the logic responsible of extracting the response from the userinfo response. + /// + public class ExtractUserinfoHttpResponse : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(DisposeHttpResponse.Descriptor.Order - 50_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public async ValueTask HandleAsync(ExtractUserinfoResponseContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // This handler only applies to System.Net.Http requests. If the HTTP response cannot be resolved, + // this may indicate that the request was incorrectly processed by another client stack. + var response = context.Transaction.GetHttpResponseMessage(); + if (response is null) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0173)); + } + + // The status code is deliberately not validated to ensure even errored responses + // (typically in the 4xx range) can be deserialized and handled by the event handlers. + + // Note: userinfo responses can be of two types: + // - application/json responses containing a JSON object listing the user claims as-is. + // - application/jwt responses containing a signed/encrypted JSON Web Token containing the user claims. + // + // As such, this handler implements a selection routine to extract the userinfo token as-is + // if the media type is application/jwt and fall back to JSON in any other case. + + if (string.Equals(response.Content.Headers.ContentType?.MediaType, + ContentTypes.JsonWebToken, StringComparison.OrdinalIgnoreCase)) + { + context.Response = new OpenIddictResponse(); + context.UserinfoToken = await response.Content.ReadAsStringAsync(); + } + + else + { + // Note: ReadFromJsonAsync() automatically validates the content type and the content encoding + // and transcode the response stream if a non-UTF-8 response is returned by the remote server. + context.Response = await response.Content.ReadFromJsonAsync(); + } + } + } + } +} diff --git a/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.cs b/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.cs index cb1807f6..9699d65e 100644 --- a/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.cs +++ b/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.cs @@ -19,7 +19,8 @@ public static partial class OpenIddictClientSystemNetHttpHandlers public static ImmutableArray DefaultHandlers { get; } = ImmutableArray.Create() .AddRange(Discovery.DefaultHandlers) - .AddRange(Exchange.DefaultHandlers); + .AddRange(Exchange.DefaultHandlers) + .AddRange(Userinfo.DefaultHandlers); /// /// Contains the logic responsible of preparing an HTTP GET request message. diff --git a/src/OpenIddict.Client/OpenIddictClientEvents.Userinfo.cs b/src/OpenIddict.Client/OpenIddictClientEvents.Userinfo.cs new file mode 100644 index 00000000..b16cf7cb --- /dev/null +++ b/src/OpenIddict.Client/OpenIddictClientEvents.Userinfo.cs @@ -0,0 +1,140 @@ +/* + * 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 +{ + /// + /// Represents an event called for each request to the userinfo endpoint + /// to give the user code a chance to add parameters to the userinfo request. + /// + public class PrepareUserinfoRequestContext : BaseExternalContext + { + /// + /// Creates a new instance of the class. + /// + public PrepareUserinfoRequestContext(OpenIddictClientTransaction transaction) + : base(transaction) + { + } + + /// + /// Gets or sets the request. + /// + public OpenIddictRequest Request + { + get => Transaction.Request!; + set => Transaction.Request = value; + } + } + + /// + /// Represents an event called for each request to the userinfo endpoint + /// to send the userinfo request to the remote authorization server. + /// + public class ApplyUserinfoRequestContext : BaseExternalContext + { + /// + /// Creates a new instance of the class. + /// + public ApplyUserinfoRequestContext(OpenIddictClientTransaction transaction) + : base(transaction) + { + } + + /// + /// Gets or sets the request. + /// + public OpenIddictRequest Request + { + get => Transaction.Request!; + set => Transaction.Request = value; + } + } + + /// + /// Represents an event called for each userinfo response + /// to extract the response parameters from the server response. + /// + public class ExtractUserinfoResponseContext : BaseExternalContext + { + /// + /// Creates a new instance of the class. + /// + public ExtractUserinfoResponseContext(OpenIddictClientTransaction transaction) + : base(transaction) + { + } + + /// + /// Gets or sets the request. + /// + public OpenIddictRequest Request + { + get => Transaction.Request!; + set => Transaction.Request = value; + } + + /// + /// Gets or sets the response, or null if it wasn't extracted yet. + /// + public OpenIddictResponse? Response + { + get => Transaction.Response; + set => Transaction.Response = value; + } + + /// + /// Gets or sets the userinfo token, if available. + /// + public string? UserinfoToken { get; set; } + } + + /// + /// Represents an event called for each userinfo response. + /// + public class HandleUserinfoResponseContext : BaseExternalContext + { + /// + /// Creates a new instance of the class. + /// + public HandleUserinfoResponseContext(OpenIddictClientTransaction transaction) + : base(transaction) + { + } + + /// + /// Gets or sets the request. + /// + public OpenIddictRequest Request + { + get => Transaction.Request!; + set => Transaction.Request = value; + } + + /// + /// Gets or sets the response. + /// + public OpenIddictResponse Response + { + get => Transaction.Response!; + set => Transaction.Response = value; + } + + /// + /// Gets or sets the userinfo token, if available. + /// + public string? UserinfoToken { get; set; } + + /// + /// Gets or sets the principal containing the claims resolved from the userinfo response. + /// + public ClaimsPrincipal? Principal { get; set; } + } +} diff --git a/src/OpenIddict.Client/OpenIddictClientEvents.cs b/src/OpenIddict.Client/OpenIddictClientEvents.cs index 7bc8d5ff..86380185 100644 --- a/src/OpenIddict.Client/OpenIddictClientEvents.cs +++ b/src/OpenIddict.Client/OpenIddictClientEvents.cs @@ -284,6 +284,14 @@ public static partial class OpenIddictClientEvents set => Transaction.Request = value; } + /// + /// Gets or sets a boolean indicating whether an authorization + /// code should be extracted from the current context. + /// Note: overriding the value of this property is generally not + /// recommended, except when dealing with non-standard clients. + /// + public bool ExtractAuthorizationCode { get; set; } + /// /// Gets or sets a boolean indicating whether a backchannel /// access token should be extracted from the current context. @@ -301,44 +309,52 @@ public static partial class OpenIddictClientEvents public bool ExtractBackchannelIdentityToken { get; set; } /// - /// Gets or sets a boolean indicating whether a backchannel - /// refresh token should be extracted from the current context. + /// Gets or sets a boolean indicating whether a frontchannel + /// access token should be extracted from the current context. /// Note: overriding the value of this property is generally not /// recommended, except when dealing with non-standard clients. /// - public bool ExtractBackchannelRefreshToken { get; set; } + public bool ExtractFrontchannelAccessToken { get; set; } /// /// Gets or sets a boolean indicating whether a frontchannel - /// access token should be extracted from the current context. + /// identity token should be extracted from the current context. /// Note: overriding the value of this property is generally not /// recommended, except when dealing with non-standard clients. /// - public bool ExtractFrontchannelAccessToken { get; set; } + public bool ExtractFrontchannelIdentityToken { get; set; } /// - /// Gets or sets a boolean indicating whether a frontchannel - /// authorization code should be extracted from the current context. + /// Gets or sets a boolean indicating whether a refresh + /// token should be extracted from the current context. /// Note: overriding the value of this property is generally not /// recommended, except when dealing with non-standard clients. /// - public bool ExtractFrontchannelAuthorizationCode { get; set; } + public bool ExtractRefreshToken { get; set; } /// - /// Gets or sets a boolean indicating whether a frontchannel - /// identity token should be extracted from the current context. + /// Gets or sets a boolean indicating whether a state + /// token should be extracted from the current context. /// Note: overriding the value of this property is generally not /// recommended, except when dealing with non-standard clients. /// - public bool ExtractFrontchannelIdentityToken { get; set; } + public bool ExtractStateToken { get; set; } /// - /// Gets or sets a boolean indicating whether a frontchannel - /// state token should be extracted from the current context. + /// Gets or sets a boolean indicating whether a userinfo + /// token should be extracted from the current context. /// Note: overriding the value of this property is generally not /// recommended, except when dealing with non-standard clients. /// - public bool ExtractFrontchannelStateToken { get; set; } + public bool ExtractUserinfoToken { get; set; } + + /// + /// Gets or sets a boolean indicating whether an authorization + /// code must be resolved for the authentication to be considered valid. + /// Note: overriding the value of this property is generally not + /// recommended, except when dealing with non-standard clients. + /// + public bool RequireAuthorizationCode { get; set; } /// /// Gets or sets a boolean indicating whether a backchannel access @@ -357,12 +373,12 @@ public static partial class OpenIddictClientEvents public bool RequireBackchannelIdentityToken { get; set; } /// - /// Gets or sets a boolean indicating whether a backchannel refresh + /// Gets or sets a boolean indicating whether a frontchannel identity /// token must be resolved for the authentication to be considered valid. /// Note: overriding the value of this property is generally not /// recommended, except when dealing with non-standard clients. /// - public bool RequireBackchannelRefreshToken { get; set; } + public bool RequireFrontchannelAccessToken { get; set; } /// /// Gets or sets a boolean indicating whether a frontchannel identity @@ -370,31 +386,39 @@ public static partial class OpenIddictClientEvents /// Note: overriding the value of this property is generally not /// recommended, except when dealing with non-standard clients. /// - public bool RequireFrontchannelAccessToken { get; set; } + public bool RequireFrontchannelIdentityToken { get; set; } /// - /// Gets or sets a boolean indicating whether a backchannel authorization - /// code must be resolved for the authentication to be considered valid. + /// Gets or sets a boolean indicating whether a refresh token + /// must be resolved for the authentication to be considered valid. /// Note: overriding the value of this property is generally not /// recommended, except when dealing with non-standard clients. /// - public bool RequireFrontchannelAuthorizationCode { get; set; } + public bool RequireRefreshToken { get; set; } /// - /// Gets or sets a boolean indicating whether a frontchannel identity - /// token must be resolved for the authentication to be considered valid. + /// Gets or sets a boolean indicating whether a state token + /// must be resolved for the authentication to be considered valid. /// Note: overriding the value of this property is generally not /// recommended, except when dealing with non-standard clients. /// - public bool RequireFrontchannelIdentityToken { get; set; } + public bool RequireStateToken { get; set; } /// - /// Gets or sets a boolean indicating whether a frontchannel state token + /// Gets or sets a boolean indicating whether a userinfo token /// must be resolved for the authentication to be considered valid. /// Note: overriding the value of this property is generally not /// recommended, except when dealing with non-standard clients. /// - public bool RequireFrontchannelStateToken { get; set; } + public bool RequireUserinfoToken { get; set; } + + /// + /// Gets or sets a boolean indicating whether the authorization + /// code extracted from the current context should be validated. + /// Note: overriding the value of this property is generally not + /// recommended, except when dealing with non-standard clients. + /// + public bool ValidateAuthorizationCode { get; set; } /// /// Gets or sets a boolean indicating whether the backchannel access @@ -413,44 +437,49 @@ public static partial class OpenIddictClientEvents public bool ValidateBackchannelIdentityToken { get; set; } /// - /// Gets or sets a boolean indicating whether the backchannel refresh token - /// extracted from the current context should be validated. + /// Gets or sets a boolean indicating whether the frontchannel access + /// token extracted from the current context should be validated. /// Note: overriding the value of this property is generally not /// recommended, except when dealing with non-standard clients. /// - public bool ValidateBackchannelRefreshToken { get; set; } + public bool ValidateFrontchannelAccessToken { get; set; } /// - /// Gets or sets a boolean indicating whether the frontchannel access + /// Gets or sets a boolean indicating whether the frontchannel identity /// token extracted from the current context should be validated. /// Note: overriding the value of this property is generally not /// recommended, except when dealing with non-standard clients. /// - public bool ValidateFrontchannelAccessToken { get; set; } + public bool ValidateFrontchannelIdentityToken { get; set; } /// - /// Gets or sets a boolean indicating whether the frontchannel authorization - /// code extracted from the current context should be validated. + /// Gets or sets a boolean indicating whether the refresh token + /// extracted from the current context should be validated. /// Note: overriding the value of this property is generally not /// recommended, except when dealing with non-standard clients. /// - public bool ValidateFrontchannelAuthorizationCode { get; set; } + public bool ValidateRefreshToken { get; set; } /// - /// Gets or sets a boolean indicating whether the frontchannel identity - /// token extracted from the current context should be validated. + /// Gets or sets a boolean indicating whether the state token + /// extracted from the current context should be validated. /// Note: overriding the value of this property is generally not /// recommended, except when dealing with non-standard clients. /// - public bool ValidateFrontchannelIdentityToken { get; set; } + public bool ValidateStateToken { get; set; } /// - /// Gets or sets a boolean indicating whether the frontchannel state token + /// Gets or sets a boolean indicating whether the userinfo token /// extracted from the current context should be validated. /// Note: overriding the value of this property is generally not /// recommended, except when dealing with non-standard clients. /// - public bool ValidateFrontchannelStateToken { get; set; } + public bool ValidateUserinfoToken { get; set; } + + /// + /// Gets or sets the authorization code to validate, if applicable. + /// + public string? AuthorizationCode { get; set; } /// /// Gets or sets the backchannel access token to validate, if applicable. @@ -462,30 +491,35 @@ public static partial class OpenIddictClientEvents /// public string? BackchannelIdentityToken { get; set; } - /// - /// Gets or sets the backchannel refresh token to validate, if applicable. - /// - public string? BackchannelRefreshToken { get; set; } - /// /// Gets or sets the frontchannel access token to validate, if applicable. /// public string? FrontchannelAccessToken { get; set; } /// - /// Gets or sets the frontchannel authorization code to validate, if applicable. + /// Gets or sets the frontchannel identity token to validate, if applicable. /// - public string? FrontchannelAuthorizationCode { get; set; } + public string? FrontchannelIdentityToken { get; set; } /// - /// Gets or sets the frontchannel identity token to validate, if applicable. + /// Gets or sets the refresh token to validate, if applicable. /// - public string? FrontchannelIdentityToken { get; set; } + public string? RefreshToken { get; set; } /// /// Gets or sets the frontchannel state token to validate, if applicable. /// - public string? FrontchannelStateToken { get; set; } + public string? StateToken { get; set; } + + /// + /// Gets or sets the userinfo token to validate, if applicable. + /// + public string? UserinfoToken { get; set; } + + /// + /// Gets or sets the principal extracted from the authorization code, if applicable. + /// + public ClaimsPrincipal? AuthorizationCodePrincipal { get; set; } /// /// Gets or sets the principal extracted from the backchannel access token, if applicable. @@ -497,11 +531,6 @@ public static partial class OpenIddictClientEvents /// public ClaimsPrincipal? BackchannelIdentityTokenPrincipal { get; set; } - /// - /// Gets or sets the principal extracted from the backchannel refresh token, if applicable. - /// - public ClaimsPrincipal? BackchannelRefreshTokenPrincipal { get; set; } - /// /// Gets or sets the principal extracted from the frontchannel access token, if applicable. /// @@ -513,14 +542,19 @@ public static partial class OpenIddictClientEvents public ClaimsPrincipal? FrontchannelIdentityTokenPrincipal { get; set; } /// - /// Gets or sets the principal extracted from the frontchannel authorization code, if applicable. + /// Gets or sets the principal extracted from the refresh token, if applicable. /// - public ClaimsPrincipal? FrontchannelAuthorizationCodePrincipal { get; set; } + public ClaimsPrincipal? RefreshTokenPrincipal { get; set; } /// - /// Gets or sets the principal extracted from the frontchannel state token, if applicable. + /// Gets or sets the principal extracted from the state token, if applicable. /// - public ClaimsPrincipal? FrontchannelStateTokenPrincipal { get; set; } + public ClaimsPrincipal? StateTokenPrincipal { get; set; } + + /// + /// Gets or sets the principal extracted from the userinfo token, if applicable. + /// + public ClaimsPrincipal? UserinfoTokenPrincipal { get; set; } /// /// Gets or sets the request sent to the token endpoint, if applicable. @@ -531,6 +565,16 @@ public static partial class OpenIddictClientEvents /// Gets or sets the response returned by the token endpoint, if applicable. /// public OpenIddictResponse? TokenResponse { get; set; } + + /// + /// Gets or sets the request sent to the userinfo endpoint, if applicable. + /// + public OpenIddictRequest? UserinfoRequest { get; set; } + + /// + /// Gets or sets the response returned by the userinfo endpoint, if applicable. + /// + public OpenIddictResponse? UserinfoResponse { get; set; } } /// diff --git a/src/OpenIddict.Client/OpenIddictClientExtensions.cs b/src/OpenIddict.Client/OpenIddictClientExtensions.cs index a24cbdd5..3ed65016 100644 --- a/src/OpenIddict.Client/OpenIddictClientExtensions.cs +++ b/src/OpenIddict.Client/OpenIddictClientExtensions.cs @@ -36,19 +36,26 @@ public static class OpenIddictClientExtensions builder.Services.TryAddSingleton(); // Register the built-in filters used by the default OpenIddict client event handlers. + builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); - builder.Services.TryAddSingleton(); - builder.Services.TryAddSingleton(); - builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); - builder.Services.TryAddSingleton(); - builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); - builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); // Register the built-in client event handlers used by the OpenIddict client components. // Note: the order used here is not important, as the actual order is set in the options. diff --git a/src/OpenIddict.Client/OpenIddictClientHandlerFilters.cs b/src/OpenIddict.Client/OpenIddictClientHandlerFilters.cs index 8cd751a5..79774859 100644 --- a/src/OpenIddict.Client/OpenIddictClientHandlerFilters.cs +++ b/src/OpenIddict.Client/OpenIddictClientHandlerFilters.cs @@ -11,6 +11,22 @@ namespace OpenIddict.Client; [EditorBrowsable(EditorBrowsableState.Advanced)] public static class OpenIddictClientHandlerFilters { + /// + /// Represents a filter that excludes the associated handlers if no authorization code is extracted. + /// + public class RequireAuthorizationCodeExtracted : IOpenIddictClientHandlerFilter + { + public ValueTask IsActiveAsync(ProcessAuthenticationContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + return new ValueTask(context.ExtractAuthorizationCode); + } + } + /// /// Represents a filter that excludes the associated handlers if the challenge /// doesn't correspond to an authorization code or implicit grant operation. @@ -28,6 +44,22 @@ public static class OpenIddictClientHandlerFilters } } + /// + /// Represents a filter that excludes the associated handlers if no authorization code is validated. + /// + public class RequireAuthorizationCodeValidated : IOpenIddictClientHandlerFilter + { + public ValueTask IsActiveAsync(ProcessAuthenticationContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + return new ValueTask(context.ValidateAuthorizationCode); + } + } + /// /// Represents a filter that excludes the associated handlers if no backchannel access token is validated. /// @@ -44,6 +76,22 @@ public static class OpenIddictClientHandlerFilters } } + /// + /// Represents a filter that excludes the associated handlers if no backchannel identity token principal is available. + /// + public class RequireBackchannelIdentityTokenPrincipal : IOpenIddictClientHandlerFilter + { + public ValueTask IsActiveAsync(ProcessAuthenticationContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + return new ValueTask(context.BackchannelIdentityTokenPrincipal is not null); + } + } + /// /// Represents a filter that excludes the associated handlers if no backchannel identity token is validated. /// @@ -61,9 +109,9 @@ public static class OpenIddictClientHandlerFilters } /// - /// Represents a filter that excludes the associated handlers if no backchannel refresh token is validated. + /// Represents a filter that excludes the associated handlers if no frontchannel access token is validated. /// - public class RequireBackchannelRefreshTokenValidated : IOpenIddictClientHandlerFilter + public class RequireFrontchannelAccessTokenValidated : IOpenIddictClientHandlerFilter { public ValueTask IsActiveAsync(ProcessAuthenticationContext context) { @@ -72,14 +120,14 @@ public static class OpenIddictClientHandlerFilters throw new ArgumentNullException(nameof(context)); } - return new ValueTask(context.ValidateBackchannelRefreshToken); + return new ValueTask(context.ValidateFrontchannelAccessToken); } } /// - /// Represents a filter that excludes the associated handlers if no backchannel request is expected to be sent. + /// Represents a filter that excludes the associated handlers if no frontchannel identity token principal is available. /// - public class RequireBackchannelRequest : IOpenIddictClientHandlerFilter + public class RequireFrontchannelIdentityTokenPrincipal : IOpenIddictClientHandlerFilter { public ValueTask IsActiveAsync(ProcessAuthenticationContext context) { @@ -88,14 +136,14 @@ public static class OpenIddictClientHandlerFilters throw new ArgumentNullException(nameof(context)); } - return new ValueTask(context.TokenRequest is not null); + return new ValueTask(context.FrontchannelIdentityTokenPrincipal is not null); } } /// - /// Represents a filter that excludes the associated handlers if no backchannel response was received. + /// Represents a filter that excludes the associated handlers if no frontchannel identity token is validated. /// - public class RequireBackchannelResponse : IOpenIddictClientHandlerFilter + public class RequireFrontchannelIdentityTokenValidated : IOpenIddictClientHandlerFilter { public ValueTask IsActiveAsync(ProcessAuthenticationContext context) { @@ -104,14 +152,30 @@ public static class OpenIddictClientHandlerFilters throw new ArgumentNullException(nameof(context)); } - return new ValueTask(context.TokenResponse is not null); + return new ValueTask(context.ValidateFrontchannelIdentityToken); } } /// - /// Represents a filter that excludes the associated handlers if no frontchannel access token is validated. + /// Represents a filter that excludes the associated handlers if the request is not a redirection request. /// - public class RequireFrontchannelAccessTokenValidated : IOpenIddictClientHandlerFilter + public class RequireRedirectionRequest : IOpenIddictClientHandlerFilter + { + public ValueTask IsActiveAsync(BaseContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + return new ValueTask(context.EndpointType == OpenIddictClientEndpointType.Redirection); + } + } + + /// + /// Represents a filter that excludes the associated handlers if no refresh token is validated. + /// + public class RequireRefreshTokenValidated : IOpenIddictClientHandlerFilter { public ValueTask IsActiveAsync(ProcessAuthenticationContext context) { @@ -120,14 +184,30 @@ public static class OpenIddictClientHandlerFilters throw new ArgumentNullException(nameof(context)); } - return new ValueTask(context.ValidateFrontchannelAccessToken); + return new ValueTask(context.ValidateRefreshToken); } } /// - /// Represents a filter that excludes the associated handlers if no frontchannel authorization code is extracted. + /// Represents a filter that excludes the associated handlers if no state token is generated. /// - public class RequireFrontchannelAuthorizationCodeExtracted : IOpenIddictClientHandlerFilter + public class RequireStateTokenGenerated : IOpenIddictClientHandlerFilter + { + public ValueTask IsActiveAsync(ProcessChallengeContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + return new ValueTask(context.GenerateStateToken); + } + } + + /// + /// Represents a filter that excludes the associated handlers if no state token principal is available. + /// + public class RequireStateTokenPrincipal : IOpenIddictClientHandlerFilter { public ValueTask IsActiveAsync(ProcessAuthenticationContext context) { @@ -136,14 +216,14 @@ public static class OpenIddictClientHandlerFilters throw new ArgumentNullException(nameof(context)); } - return new ValueTask(context.ExtractFrontchannelAuthorizationCode); + return new ValueTask(context.StateTokenPrincipal is not null); } } /// - /// Represents a filter that excludes the associated handlers if no frontchannel authorization code is validated. + /// Represents a filter that excludes the associated handlers if no state token is validated. /// - public class RequireFrontchannelAuthorizationCodeValidated : IOpenIddictClientHandlerFilter + public class RequireStateTokenValidated : IOpenIddictClientHandlerFilter { public ValueTask IsActiveAsync(ProcessAuthenticationContext context) { @@ -152,14 +232,14 @@ public static class OpenIddictClientHandlerFilters throw new ArgumentNullException(nameof(context)); } - return new ValueTask(context.ValidateFrontchannelAuthorizationCode); + return new ValueTask(context.ValidateStateToken); } } /// - /// Represents a filter that excludes the associated handlers if no frontchannel identity token is validated. + /// Represents a filter that excludes the associated handlers if no token request is expected to be sent. /// - public class RequireFrontchannelIdentityTokenValidated : IOpenIddictClientHandlerFilter + public class RequireTokenRequest : IOpenIddictClientHandlerFilter { public ValueTask IsActiveAsync(ProcessAuthenticationContext context) { @@ -168,14 +248,14 @@ public static class OpenIddictClientHandlerFilters throw new ArgumentNullException(nameof(context)); } - return new ValueTask(context.ValidateFrontchannelIdentityToken); + return new ValueTask(context.TokenRequest is not null); } } /// - /// Represents a filter that excludes the associated handlers if no frontchannel state token is validated. + /// Represents a filter that excludes the associated handlers if no token response was received. /// - public class RequireFrontchannelStateTokenValidated : IOpenIddictClientHandlerFilter + public class RequireTokenResponse : IOpenIddictClientHandlerFilter { public ValueTask IsActiveAsync(ProcessAuthenticationContext context) { @@ -184,39 +264,71 @@ public static class OpenIddictClientHandlerFilters throw new ArgumentNullException(nameof(context)); } - return new ValueTask(context.ValidateFrontchannelStateToken); + return new ValueTask(context.TokenResponse is not null); } } /// - /// Represents a filter that excludes the associated handlers if the request is not a redirection request. + /// Represents a filter that excludes the associated handlers if no userinfo request is expected to be sent. /// - public class RequireRedirectionRequest : IOpenIddictClientHandlerFilter + public class RequireUserinfoRequest : IOpenIddictClientHandlerFilter { - public ValueTask IsActiveAsync(BaseContext context) + public ValueTask IsActiveAsync(ProcessAuthenticationContext context) { if (context is null) { throw new ArgumentNullException(nameof(context)); } - return new ValueTask(context.EndpointType == OpenIddictClientEndpointType.Redirection); + return new ValueTask(context.UserinfoRequest is not null); } } /// - /// Represents a filter that excludes the associated handlers if no state token is generated. + /// Represents a filter that excludes the associated handlers if no userinfo response was received. /// - public class RequireStateTokenGenerated : IOpenIddictClientHandlerFilter + public class RequireUserinfoResponse : IOpenIddictClientHandlerFilter { - public ValueTask IsActiveAsync(ProcessChallengeContext context) + public ValueTask IsActiveAsync(ProcessAuthenticationContext context) { if (context is null) { throw new ArgumentNullException(nameof(context)); } - return new ValueTask(context.GenerateStateToken); + return new ValueTask(context.UserinfoResponse is not null); + } + } + + /// + /// Represents a filter that excludes the associated handlers if no userinfo token is extracted. + /// + public class RequireUserinfoTokenExtracted : IOpenIddictClientHandlerFilter + { + public ValueTask IsActiveAsync(ProcessAuthenticationContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + return new ValueTask(context.ExtractUserinfoToken); + } + } + + /// + /// Represents a filter that excludes the associated handlers if no userinfo token principal is available. + /// + public class RequireUserinfoTokenPrincipal : IOpenIddictClientHandlerFilter + { + public ValueTask IsActiveAsync(ProcessAuthenticationContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + return new ValueTask(context.UserinfoTokenPrincipal is not null); } } } diff --git a/src/OpenIddict.Client/OpenIddictClientHandlers.Authentication.cs b/src/OpenIddict.Client/OpenIddictClientHandlers.Authentication.cs index 12639c0e..ba6eaed1 100644 --- a/src/OpenIddict.Client/OpenIddictClientHandlers.Authentication.cs +++ b/src/OpenIddict.Client/OpenIddictClientHandlers.Authentication.cs @@ -500,9 +500,9 @@ public static partial class OpenIddictClientHandlers return; } - // Attach the security principal extracted from the token to the validation context. + // Attach the security principals extracted from the tokens to the validation context. context.Principal = notification.FrontchannelIdentityTokenPrincipal; - context.StateTokenPrincipal = notification.FrontchannelStateTokenPrincipal; + context.StateTokenPrincipal = notification.StateTokenPrincipal; } } } diff --git a/src/OpenIddict.Client/OpenIddictClientHandlers.Discovery.cs b/src/OpenIddict.Client/OpenIddictClientHandlers.Discovery.cs index fd75f692..433bbebb 100644 --- a/src/OpenIddict.Client/OpenIddictClientHandlers.Discovery.cs +++ b/src/OpenIddict.Client/OpenIddictClientHandlers.Discovery.cs @@ -22,13 +22,14 @@ public static partial class OpenIddictClientHandlers ExtractAuthorizationEndpoint.Descriptor, ExtractCryptographyEndpoint.Descriptor, ExtractTokenEndpoint.Descriptor, - ExtractTokenEndpointClientAuthenticationMethods.Descriptor, + ExtractUserinfoEndpoint.Descriptor, ExtractGrantTypes.Descriptor, ExtractResponseModes.Descriptor, ExtractResponseTypes.Descriptor, ExtractCodeChallengeMethods.Descriptor, ExtractScopes.Descriptor, ExtractIssuerParameterRequirement.Descriptor, + ExtractTokenEndpointClientAuthenticationMethods.Descriptor, /* * Cryptography response handling: @@ -88,6 +89,58 @@ public static partial class OpenIddictClientHandlers } } + /// + /// Contains the logic responsible of extracting the authorization endpoint address from the discovery document. + /// + public class ExtractAuthorizationEndpoint : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ValidateIssuer.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(HandleConfigurationResponseContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // Note: the authorization_endpoint node is required by the OpenID Connect discovery specification + // but is optional in the OAuth 2.0 authorization server metadata specification. To make OpenIddict + // compatible with the newer OAuth 2.0 specification, null/empty and missing values are allowed here. + // + // Handlers that require a non-null authorization endpoint URL are expected to return an error + // if the authorization endpoint URL couldn't be resolved from the authorization server metadata. + // See https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationClient + // and https://datatracker.ietf.org/doc/html/rfc8414#section-2 for more information. + // + var address = (string?) context.Response[Metadata.AuthorizationEndpoint]; + if (!string.IsNullOrEmpty(address)) + { + if (!Uri.TryCreate(address, UriKind.Absolute, out Uri? uri) || !uri.IsWellFormedOriginalString()) + { + context.Reject( + error: Errors.ServerError, + description: SR.FormatID2100(Metadata.AuthorizationEndpoint), + uri: SR.FormatID8000(SR.ID2100)); + + return default; + } + + context.Configuration.AuthorizationEndpoint = uri; + } + + return default; + } + } + /// /// Contains the logic responsible of extracting the JWKS endpoint address from the discovery document. /// @@ -99,7 +152,7 @@ public static partial class OpenIddictClientHandlers public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() .UseSingletonHandler() - .SetOrder(ValidateIssuer.Descriptor.Order + 1_000) + .SetOrder(ExtractAuthorizationEndpoint.Descriptor.Order + 1_000) .SetType(OpenIddictClientHandlerType.BuiltIn) .Build(); @@ -184,17 +237,16 @@ public static partial class OpenIddictClientHandlers } /// - /// Contains the logic responsible of extracting the authentication methods - /// supported by the token endpoint from the discovery document. + /// Contains the logic responsible of extracting the userinfo endpoint address from the discovery document. /// - public class ExtractTokenEndpointClientAuthenticationMethods : IOpenIddictClientHandler + public class ExtractUserinfoEndpoint : IOpenIddictClientHandler { /// /// Gets the default descriptor definition assigned to this handler. /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .UseSingletonHandler() + .UseSingletonHandler() .SetOrder(ExtractTokenEndpoint.Descriptor.Order + 1_000) .SetType(OpenIddictClientHandlerType.BuiltIn) .Build(); @@ -207,71 +259,20 @@ public static partial class OpenIddictClientHandlers throw new ArgumentNullException(nameof(context)); } - // Resolve the client authentication methods supported by the token endpoint, if available. - var methods = context.Response[Metadata.TokenEndpointAuthMethodsSupported]?.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.TokenEndpointAuthMethodsSupported.Add(method); - } - } - } - - return default; - } - } - - /// - /// Contains the logic responsible of extracting the authorization endpoint address from the discovery document. - /// - public class ExtractAuthorizationEndpoint : IOpenIddictClientHandler - { - /// - /// Gets the default descriptor definition assigned to this handler. - /// - public static OpenIddictClientHandlerDescriptor Descriptor { get; } - = OpenIddictClientHandlerDescriptor.CreateBuilder() - .UseSingletonHandler() - .SetOrder(ExtractTokenEndpointClientAuthenticationMethods.Descriptor.Order + 1_000) - .SetType(OpenIddictClientHandlerType.BuiltIn) - .Build(); - - /// - public ValueTask HandleAsync(HandleConfigurationResponseContext context) - { - if (context is null) - { - throw new ArgumentNullException(nameof(context)); - } - - // Note: the authorization_endpoint node is required by the OpenID Connect discovery specification - // but is optional in the OAuth 2.0 authorization server metadata specification. To make OpenIddict - // compatible with the newer OAuth 2.0 specification, null/empty and missing values are allowed here. - // - // Handlers that require a non-null authorization endpoint URL are expected to return an error - // if the authorization endpoint URL couldn't be resolved from the authorization server metadata. - // See https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationClient - // and https://datatracker.ietf.org/doc/html/rfc8414#section-2 for more information. - // - var address = (string?) context.Response[Metadata.AuthorizationEndpoint]; + var address = (string?) context.Response[Metadata.UserinfoEndpoint]; if (!string.IsNullOrEmpty(address)) { if (!Uri.TryCreate(address, UriKind.Absolute, out Uri? uri) || !uri.IsWellFormedOriginalString()) { context.Reject( error: Errors.ServerError, - description: SR.FormatID2100(Metadata.AuthorizationEndpoint), + description: SR.FormatID2100(Metadata.UserinfoEndpoint), uri: SR.FormatID8000(SR.ID2100)); return default; } - context.Configuration.AuthorizationEndpoint = uri; + context.Configuration.UserinfoEndpoint = uri; } return default; @@ -519,6 +520,49 @@ public static partial class OpenIddictClientHandlers } } + /// + /// Contains the logic responsible of extracting the authentication methods + /// supported by the token endpoint from the discovery document. + /// + public class ExtractTokenEndpointClientAuthenticationMethods : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ExtractIssuerParameterRequirement.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(HandleConfigurationResponseContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // Resolve the client authentication methods supported by the token endpoint, if available. + var methods = context.Response[Metadata.TokenEndpointAuthMethodsSupported]?.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.TokenEndpointAuthMethodsSupported.Add(method); + } + } + } + + return default; + } + } + /// /// Contains the logic responsible of extracting the signing keys from the JWKS document. /// diff --git a/src/OpenIddict.Client/OpenIddictClientHandlers.Protection.cs b/src/OpenIddict.Client/OpenIddictClientHandlers.Protection.cs index fa041b79..6a98612a 100644 --- a/src/OpenIddict.Client/OpenIddictClientHandlers.Protection.cs +++ b/src/OpenIddict.Client/OpenIddictClientHandlers.Protection.cs @@ -212,20 +212,33 @@ public static partial class OpenIddictClientHandlers _ => true // Allow any other claim. }); - // Attach the principal extracted from the token to the parent event context and store - // the token type (resolved from "typ" or "token_usage") as a special private claim. - context.Principal = new ClaimsPrincipal(identity).SetTokenType(result.TokenType switch + if (context.ValidTokenTypes.Contains(TokenTypeHints.StateToken)) { - null or { Length: 0 } => throw new InvalidOperationException(SR.GetResourceString(SR.ID0025)), + // Attach the principal extracted from the token to the parent event context and store + // the token type (resolved from "typ" or "token_usage") as a special private claim. + context.Principal = new ClaimsPrincipal(identity).SetTokenType(result.TokenType switch + { + null or { Length: 0 } => throw new InvalidOperationException(SR.GetResourceString(SR.ID0025)), - // Both JWT and application/JWT are supported for identity tokens. - JsonWebTokenTypes.IdentityToken or JsonWebTokenTypes.Prefixes.Application + JsonWebTokenTypes.IdentityToken - => TokenTypeHints.IdToken, + JsonWebTokenTypes.Private.StateToken => TokenTypeHints.StateToken, - JsonWebTokenTypes.Private.StateToken => TokenTypeHints.StateToken, + _ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0003)) + }); + } - _ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0003)) - }); + else if (context.ValidTokenTypes.Count is 1) + { + // JSON Web Tokens defined by the OpenID Connect core specification (e.g identity or userinfo tokens) + // don't have to include a specific "typ" header and all values are allowed. As such, the tokens + // as assumed to be of the type that is expected by the authentication routine. Additional checks + // like audience validation can be implemented to prevent tokens mix-up/confused deputy attacks. + context.Principal = new ClaimsPrincipal(identity).SetTokenType(context.ValidTokenTypes.Single()); + } + + else + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0308)); + } // Store the resolved signing algorithm from the token and attach it to the principal. context.Principal.SetClaim(Claims.Private.SigningAlgorithm, token.Alg); diff --git a/src/OpenIddict.Client/OpenIddictClientHandlers.Userinfo.cs b/src/OpenIddict.Client/OpenIddictClientHandlers.Userinfo.cs new file mode 100644 index 00000000..e2150491 --- /dev/null +++ b/src/OpenIddict.Client/OpenIddictClientHandlers.Userinfo.cs @@ -0,0 +1,214 @@ +/* + * 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.Globalization; +using System.Security.Claims; +using System.Text.Json; +using Microsoft.IdentityModel.JsonWebTokens; + +namespace OpenIddict.Client; + +public static partial class OpenIddictClientHandlers +{ + public static class Userinfo + { + public static ImmutableArray DefaultHandlers { get; } = ImmutableArray.Create( + /* + * Userinfo response handling: + */ + HandleErrorResponse.Descriptor, + ValidateWellKnownClaims.Descriptor, + PopulateClaims.Descriptor); + + /// + /// Contains the logic responsible of validating the well-known parameters contained in the userinfo response. + /// + public class ValidateWellKnownClaims : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(int.MinValue + 100_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(HandleUserinfoResponseContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // Ignore the response instance if a userinfo token was extracted. + if (!string.IsNullOrEmpty(context.UserinfoToken)) + { + return default; + } + + foreach (var parameter in context.Response.GetParameters()) + { + if (ValidateClaimType(parameter.Key, parameter.Value.Value)) + { + continue; + } + + context.Reject( + error: Errors.ServerError, + description: SR.FormatID2107(parameter.Key), + uri: SR.FormatID8000(SR.ID2107)); + + return default; + } + + return default; + + static bool ValidateClaimType(string name, object? value) => name switch + { + // The 'sub' parameter MUST be formatted as a string value. + Claims.Subject => value is string or JsonElement { ValueKind: JsonValueKind.String }, + + // Parameters that are not in the well-known list can be of any type. + _ => true + }; + } + } + + /// + /// Contains the logic responsible of extracting the claims from the introspection response. + /// + public class PopulateClaims : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ValidateWellKnownClaims.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(HandleUserinfoResponseContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // Ignore the response instance if a userinfo token was extracted. + if (!string.IsNullOrEmpty(context.UserinfoToken)) + { + return default; + } + + // 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); + + // Resolve the issuer that will be attached to the claims created by this handler. + // Note: at this stage, the optional issuer extracted from the response is assumed + // to be valid, as it is guarded against unknown values by the ValidateIssuer handler. + var issuer = (string?) context.Response[Claims.Issuer] ?? context.Issuer?.AbsoluteUri ?? ClaimsIdentity.DefaultIssuer; + + foreach (var parameter in context.Response.GetParameters()) + { + // Always exclude null keys and values, as they can't be represented as valid claims. + if (string.IsNullOrEmpty(parameter.Key) || OpenIddictParameter.IsNullOrEmpty(parameter.Value)) + { + 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; + } + + switch (parameter.Value.Value) + { + // Claims represented as arrays are split and mapped to multiple CLR claims. + case JsonElement { ValueKind: JsonValueKind.Array } value: + foreach (var element in value.EnumerateArray()) + { + var item = element.GetString(); + if (string.IsNullOrEmpty(item)) + { + continue; + } + + identity.AddClaim(new Claim(parameter.Key, item, + GetClaimValueType(value.ValueKind), issuer, issuer, identity)); + } + break; + + case JsonElement value: + identity.AddClaim(new Claim(parameter.Key, value.ToString()!, + GetClaimValueType(value.ValueKind), issuer, issuer, identity)); + break; + + // Note: in the typical case, the introspection parameters should be deserialized from + // a JSON response and thus represented as System.Text.Json.JsonElement instances. + // However, to support responses resolved from custom locations and parameters manually added + // by the application using the events model, the CLR primitive types are also supported. + + case bool value: + identity.AddClaim(new Claim(parameter.Key, value.ToString(), + ClaimValueTypes.Boolean, issuer, issuer, identity)); + break; + + case long value: + identity.AddClaim(new Claim(parameter.Key, value.ToString(CultureInfo.InvariantCulture), + ClaimValueTypes.Integer64, issuer, issuer, identity)); + break; + + case string value: + identity.AddClaim(new Claim(parameter.Key, value, ClaimValueTypes.String, issuer, issuer, identity)); + break; + + // Claims represented as arrays are split and mapped to multiple CLR claims. + case string[] value: + for (var index = 0; index < value.Length; index++) + { + identity.AddClaim(new Claim(parameter.Key, value[index], ClaimValueTypes.String, issuer, issuer, identity)); + } + break; + } + } + + context.Principal = new ClaimsPrincipal(identity); + + return default; + + static string GetClaimValueType(JsonValueKind kind) => kind switch + { + JsonValueKind.True or JsonValueKind.False => ClaimValueTypes.Boolean, + + JsonValueKind.String => ClaimValueTypes.String, + JsonValueKind.Number => ClaimValueTypes.Integer64, + + JsonValueKind.Array => JsonClaimValueTypes.JsonArray, + JsonValueKind.Object or _ => JsonClaimValueTypes.Json + }; + } + } + } +} diff --git a/src/OpenIddict.Client/OpenIddictClientHandlers.cs b/src/OpenIddict.Client/OpenIddictClientHandlers.cs index 4cbed4b2..d0050572 100644 --- a/src/OpenIddict.Client/OpenIddictClientHandlers.cs +++ b/src/OpenIddict.Client/OpenIddictClientHandlers.cs @@ -27,9 +27,9 @@ public static partial class OpenIddictClientHandlers */ ValidateAuthenticationDemand.Descriptor, EvaluateValidatedUpfrontTokens.Descriptor, - ResolveUpfrontTokens.Descriptor, - ValidateRequiredUpfrontTokens.Descriptor, - ValidateFrontchannelStateToken.Descriptor, + ResolveValidatedStateToken.Descriptor, + ValidateRequiredStateToken.Descriptor, + ValidateStateToken.Descriptor, ResolveClientRegistrationFromStateToken.Descriptor, ValidateIssuerParameter.Descriptor, ValidateFrontchannelErrorParameters.Descriptor, @@ -47,13 +47,13 @@ public static partial class OpenIddictClientHandlers ValidateFrontchannelTokenDigests.Descriptor, ValidateFrontchannelAccessToken.Descriptor, - ValidateFrontchannelAuthorizationCode.Descriptor, + ValidateAuthorizationCode.Descriptor, EvaluateValidatedBackchannelTokens.Descriptor, - AttachBackchannelRequestParameters.Descriptor, - SendBackchannelRequest.Descriptor, - ValidateBackchannelErrorParameters.Descriptor, + AttachTokenRequestParameters.Descriptor, + SendTokenRequest.Descriptor, + ValidateTokenErrorParameters.Descriptor, ResolveValidatedBackchannelTokens.Descriptor, ValidateRequiredBackchannelTokens.Descriptor, @@ -66,7 +66,16 @@ public static partial class OpenIddictClientHandlers ValidateBackchannelTokenDigests.Descriptor, ValidateBackchannelAccessToken.Descriptor, - ValidateBackchannelRefreshToken.Descriptor, + ValidateRefreshToken.Descriptor, + + EvaluateValidatedUserinfoToken.Descriptor, + AttachUserinfoRequestParameters.Descriptor, + SendUserinfoRequest.Descriptor, + ValidateUserinfoErrorParameters.Descriptor, + ValidateRequiredUserinfoToken.Descriptor, + ValidateUserinfoToken.Descriptor, + ValidateUserinfoTokenWellknownClaims.Descriptor, + ValidateUserinfoTokenWellknownSubject.Descriptor, /* * Challenge processing: @@ -95,7 +104,8 @@ public static partial class OpenIddictClientHandlers .AddRange(Authentication.DefaultHandlers) .AddRange(Discovery.DefaultHandlers) - .AddRange(Protection.DefaultHandlers); + .AddRange(Protection.DefaultHandlers) + .AddRange(Userinfo.DefaultHandlers); /// /// Contains the logic responsible of rejecting invalid authentication demands. @@ -155,9 +165,9 @@ public static partial class OpenIddictClientHandlers throw new ArgumentNullException(nameof(context)); } - (context.ExtractFrontchannelStateToken, - context.RequireFrontchannelStateToken, - context.ValidateFrontchannelStateToken) = context.EndpointType switch + (context.ExtractStateToken, + context.RequireStateToken, + context.ValidateStateToken) = context.EndpointType switch { // While the OAuth 2.0/2.1 and OpenID Connect specifications don't require sending a // state as part of authorization requests, the identity provider MUST return the state @@ -174,16 +184,16 @@ public static partial class OpenIddictClientHandlers } /// - /// Contains the logic responsible of resolving the tokens to validate upfront from the incoming request. + /// Contains the logic responsible of resolving the state token to validate upfront from the incoming request. /// - public class ResolveUpfrontTokens : IOpenIddictClientHandler + public class ResolveValidatedStateToken : IOpenIddictClientHandler { /// /// Gets the default descriptor definition assigned to this handler. /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .UseSingletonHandler() + .UseSingletonHandler() .SetOrder(EvaluateValidatedUpfrontTokens.Descriptor.Order + 1_000) .SetType(OpenIddictClientHandlerType.BuiltIn) .Build(); @@ -196,9 +206,9 @@ public static partial class OpenIddictClientHandlers throw new ArgumentNullException(nameof(context)); } - context.FrontchannelStateToken = context.EndpointType switch + context.StateToken = context.EndpointType switch { - OpenIddictClientEndpointType.Redirection when context.ExtractFrontchannelStateToken + OpenIddictClientEndpointType.Redirection when context.ExtractStateToken => context.Request.State, _ => null @@ -209,9 +219,9 @@ public static partial class OpenIddictClientHandlers } /// - /// Contains the logic responsible of rejecting authentication demands that lack required upfront tokens. + /// Contains the logic responsible of rejecting authentication demands that lack the required state token. /// - public class ValidateRequiredUpfrontTokens : IOpenIddictClientHandler + public class ValidateRequiredStateToken : IOpenIddictClientHandler { /// /// Gets the default descriptor definition assigned to this handler. @@ -219,7 +229,7 @@ public static partial class OpenIddictClientHandlers public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() .UseSingletonHandler() - .SetOrder(ResolveUpfrontTokens.Descriptor.Order + 1_000) + .SetOrder(ResolveValidatedStateToken.Descriptor.Order + 1_000) .SetType(OpenIddictClientHandlerType.BuiltIn) .Build(); @@ -231,7 +241,7 @@ public static partial class OpenIddictClientHandlers throw new ArgumentNullException(nameof(context)); } - if (context.RequireFrontchannelStateToken && string.IsNullOrEmpty(context.FrontchannelStateToken)) + if (context.RequireStateToken && string.IsNullOrEmpty(context.StateToken)) { context.Reject( error: Errors.MissingToken, @@ -248,11 +258,11 @@ public static partial class OpenIddictClientHandlers /// /// Contains the logic responsible of validating the state token resolved from the context. /// - public class ValidateFrontchannelStateToken : IOpenIddictClientHandler + public class ValidateStateToken : IOpenIddictClientHandler { private readonly IOpenIddictClientDispatcher _dispatcher; - public ValidateFrontchannelStateToken(IOpenIddictClientDispatcher dispatcher) + public ValidateStateToken(IOpenIddictClientDispatcher dispatcher) => _dispatcher = dispatcher; /// @@ -260,9 +270,9 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() - .UseScopedHandler() - .SetOrder(ValidateRequiredUpfrontTokens.Descriptor.Order + 1_000) + .AddFilter() + .UseScopedHandler() + .SetOrder(ValidateRequiredStateToken.Descriptor.Order + 1_000) .SetType(OpenIddictClientHandlerType.BuiltIn) .Build(); @@ -274,15 +284,15 @@ public static partial class OpenIddictClientHandlers throw new ArgumentNullException(nameof(context)); } - if (context.FrontchannelStateTokenPrincipal is not null || - string.IsNullOrEmpty(context.FrontchannelStateToken)) + if (context.StateTokenPrincipal is not null || + string.IsNullOrEmpty(context.StateToken)) { return; } var notification = new ValidateTokenContext(context.Transaction) { - Token = context.FrontchannelStateToken, + Token = context.StateToken, ValidTokenTypes = { TokenTypeHints.StateToken } }; @@ -309,7 +319,7 @@ public static partial class OpenIddictClientHandlers return; } - context.FrontchannelStateTokenPrincipal = notification.Principal; + context.StateTokenPrincipal = notification.Principal; } } @@ -324,9 +334,9 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() + .AddFilter() .UseSingletonHandler() - .SetOrder(ValidateFrontchannelStateToken.Descriptor.Order + 1_000) + .SetOrder(ValidateStateToken.Descriptor.Order + 1_000) .Build(); /// @@ -337,7 +347,7 @@ public static partial class OpenIddictClientHandlers throw new ArgumentNullException(nameof(context)); } - Debug.Assert(context.FrontchannelStateTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); + Debug.Assert(context.StateTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); // Retrieve the client definition using the authorization server stored in the state token. // @@ -349,7 +359,7 @@ public static partial class OpenIddictClientHandlers // Restore the identity of the authorization server from the special "as" claim. // See https://datatracker.ietf.org/doc/html/draft-bradley-oauth-jwt-encoded-state-09#section-2 // for more information. - var value = context.FrontchannelStateTokenPrincipal.GetClaim(Claims.AuthorizationServer); + var value = context.StateTokenPrincipal.GetClaim(Claims.AuthorizationServer); if (string.IsNullOrEmpty(value) || !Uri.TryCreate(value, UriKind.Absolute, out Uri? issuer) || !issuer.IsWellFormedOriginalString()) { @@ -514,7 +524,7 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() + .AddFilter() .UseSingletonHandler() .SetOrder(ValidateIssuerParameter.Descriptor.Order + 1_000) .SetType(OpenIddictClientHandlerType.BuiltIn) @@ -528,10 +538,10 @@ public static partial class OpenIddictClientHandlers throw new ArgumentNullException(nameof(context)); } - Debug.Assert(context.FrontchannelStateTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); + Debug.Assert(context.StateTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); // Resolve the negotiated grant type from the state token. - var type = context.FrontchannelStateTokenPrincipal.GetClaim(Claims.Private.GrantType); + var type = context.StateTokenPrincipal.GetClaim(Claims.Private.GrantType); // Note: OpenIddict currently only supports the implicit and authorization code // grants but additional grants (like CIBA) may be supported in future versions. @@ -564,6 +574,7 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() .UseSingletonHandler() .SetOrder(ValidateGrantType.Descriptor.Order + 1_000) .SetType(OpenIddictClientHandlerType.BuiltIn) @@ -577,47 +588,47 @@ public static partial class OpenIddictClientHandlers throw new ArgumentNullException(nameof(context)); } - Debug.Assert(context.FrontchannelStateTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); + Debug.Assert(context.StateTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); // Resolve the grant grant and the response type stored in the state token and extract its individual elements. var types = ( - GrantType: context.FrontchannelStateTokenPrincipal.GetClaim(Claims.Private.GrantType), - ResponseTypes: context.FrontchannelStateTokenPrincipal.GetClaim(Claims.Private.ResponseType) + GrantType: context.StateTokenPrincipal.GetClaim(Claims.Private.GrantType), + ResponseTypes: context.StateTokenPrincipal.GetClaim(Claims.Private.ResponseType) !.Split(Separators.Space, StringSplitOptions.RemoveEmptyEntries) .ToImmutableHashSet()); - (context.ExtractFrontchannelAccessToken, - context.RequireFrontchannelAccessToken, - context.ValidateFrontchannelAccessToken) = types switch + (context.ExtractAuthorizationCode, + context.RequireAuthorizationCode, + context.ValidateAuthorizationCode) = types switch { - // An access token is returned for the authorization code and implicit grants when - // the response type contains the "token" value, which includes some variations of - // the implicit and hybrid flows, but not the authorization code flow. As such, - // a frontchannel access token is only considered required if a token was requested. + // An authorization code is returned for the authorization code and implicit grants when + // the response type contains the "code" value, which includes the authorization code + // flow and some variations of the hybrid flow. As such, an authorization code is only + // considered required if the negotiated response_type includes "code". // - // Note: since access tokens are supposed to be opaque to the clients, they are never + // Note: since authorization codes are supposed to be opaque to the clients, they are never // validated by default. Clients that need to deal with non-standard implementations // can use custom handlers to validate access tokens that use a readable format (e.g JWT). (GrantTypes.AuthorizationCode or GrantTypes.Implicit, ImmutableHashSet set) - when set.Contains(ResponseTypes.Token) => (true, true, false), + when set.Contains(ResponseTypes.Code) => (true, true, false), _ => (false, false, false) }; - (context.ExtractFrontchannelAuthorizationCode, - context.RequireFrontchannelAuthorizationCode, - context.ValidateFrontchannelAuthorizationCode) = types switch + (context.ExtractFrontchannelAccessToken, + context.RequireFrontchannelAccessToken, + context.ValidateFrontchannelAccessToken) = types switch { - // An authorization code is returned for the authorization code and implicit grants when - // the response type contains the "code" value, which includes the authorization code - // flow and some variations of the hybrid flow. As such, an authorization code is only - // considered required if the negotiated response_type includes "code". + // An access token is returned for the authorization code and implicit grants when + // the response type contains the "token" value, which includes some variations of + // the implicit and hybrid flows, but not the authorization code flow. As such, + // a frontchannel access token is only considered required if a token was requested. // - // Note: since authorization codes are supposed to be opaque to the clients, they are never + // Note: since access tokens are supposed to be opaque to the clients, they are never // validated by default. Clients that need to deal with non-standard implementations // can use custom handlers to validate access tokens that use a readable format (e.g JWT). (GrantTypes.AuthorizationCode or GrantTypes.Implicit, ImmutableHashSet set) - when set.Contains(ResponseTypes.Code) => (true, true, false), + when set.Contains(ResponseTypes.Token) => (true, true, false), _ => (false, false, false) }; @@ -666,18 +677,18 @@ public static partial class OpenIddictClientHandlers throw new ArgumentNullException(nameof(context)); } - context.FrontchannelAccessToken = context.EndpointType switch + context.AuthorizationCode = context.EndpointType switch { - OpenIddictClientEndpointType.Redirection when context.ExtractFrontchannelAccessToken - => context.Request.AccessToken, + OpenIddictClientEndpointType.Redirection when context.ExtractAuthorizationCode + => context.Request.Code, _ => null }; - context.FrontchannelAuthorizationCode = context.EndpointType switch + context.FrontchannelAccessToken = context.EndpointType switch { - OpenIddictClientEndpointType.Redirection when context.ExtractFrontchannelAuthorizationCode - => context.Request.Code, + OpenIddictClientEndpointType.Redirection when context.ExtractFrontchannelAccessToken + => context.Request.AccessToken, _ => null }; @@ -717,9 +728,9 @@ public static partial class OpenIddictClientHandlers throw new ArgumentNullException(nameof(context)); } - if ((context.RequireFrontchannelAccessToken && string.IsNullOrEmpty(context.FrontchannelAccessToken)) || - (context.RequireFrontchannelAuthorizationCode && string.IsNullOrEmpty(context.FrontchannelAuthorizationCode)) || - (context.RequireFrontchannelIdentityToken && string.IsNullOrEmpty(context.FrontchannelIdentityToken))) + if ((context.RequireAuthorizationCode && string.IsNullOrEmpty(context.AuthorizationCode)) || + (context.RequireFrontchannelAccessToken && string.IsNullOrEmpty(context.FrontchannelAccessToken)) || + (context.RequireFrontchannelIdentityToken && string.IsNullOrEmpty(context.FrontchannelIdentityToken))) { context.Reject( error: Errors.MissingToken, @@ -811,7 +822,7 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() + .AddFilter() .UseSingletonHandler() .SetOrder(ValidateFrontchannelIdentityToken.Descriptor.Order + 1_000) .SetType(OpenIddictClientHandlerType.BuiltIn) @@ -948,7 +959,7 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() + .AddFilter() .UseSingletonHandler() .SetOrder(ValidateFrontchannelIdentityTokenWellknownClaims.Descriptor.Order + 1_000) .Build(); @@ -993,7 +1004,7 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() + .AddFilter() .UseSingletonHandler() .SetOrder(ValidateFrontchannelIdentityTokenAudience.Descriptor.Order + 1_000) .Build(); @@ -1036,8 +1047,8 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() - .AddFilter() + .AddFilter() + .AddFilter() .UseSingletonHandler() .SetOrder(ValidateFrontchannelIdentityTokenPresenter.Descriptor.Order + 1_000) .Build(); @@ -1051,11 +1062,11 @@ public static partial class OpenIddictClientHandlers } Debug.Assert(context.FrontchannelIdentityTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); - Debug.Assert(context.FrontchannelStateTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); + Debug.Assert(context.StateTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); switch (( FrontchannelIdentityTokenNonce: context.FrontchannelIdentityTokenPrincipal.GetClaim(Claims.Nonce), - StateTokenNonce: context.FrontchannelStateTokenPrincipal.GetClaim(Claims.Private.Nonce))) + StateTokenNonce: context.StateTokenPrincipal.GetClaim(Claims.Private.Nonce))) { // If no nonce if no present in the state token (e.g because the authorization server doesn't // support OpenID Connect and response_type=code was negotiated), bypass the validation logic. @@ -1100,7 +1111,7 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() + .AddFilter() .UseSingletonHandler() .SetOrder(ValidateFrontchannelIdentityTokenNonce.Descriptor.Order + 1_000) .Build(); @@ -1161,7 +1172,7 @@ public static partial class OpenIddictClientHandlers // If an authorization code was returned in the authorization response, // ensure the c_hash claim matches the hash of the actual authorization code. - if (!string.IsNullOrEmpty(context.FrontchannelAuthorizationCode)) + if (!string.IsNullOrEmpty(context.AuthorizationCode)) { var hash = context.FrontchannelIdentityTokenPrincipal.GetClaim(Claims.CodeHash); if (string.IsNullOrEmpty(hash)) @@ -1174,7 +1185,7 @@ public static partial class OpenIddictClientHandlers return default; } - if (!ValidateTokenHash(algorithm, context.FrontchannelAuthorizationCode, hash)) + if (!ValidateTokenHash(algorithm, context.AuthorizationCode, hash)) { context.Reject( error: Errors.InvalidRequest, @@ -1297,15 +1308,15 @@ public static partial class OpenIddictClientHandlers } /// - /// Contains the logic responsible of validating the frontchannel authorization code resolved from the context. + /// Contains the logic responsible of validating the authorization code resolved from the context. /// Note: this handler is typically not used for standard-compliant implementations as authorization codes /// are supposed to be opaque to clients. /// - public class ValidateFrontchannelAuthorizationCode : IOpenIddictClientHandler + public class ValidateAuthorizationCode : IOpenIddictClientHandler { private readonly IOpenIddictClientDispatcher _dispatcher; - public ValidateFrontchannelAuthorizationCode(IOpenIddictClientDispatcher dispatcher) + public ValidateAuthorizationCode(IOpenIddictClientDispatcher dispatcher) => _dispatcher = dispatcher; /// @@ -1313,8 +1324,8 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() - .UseScopedHandler() + .AddFilter() + .UseScopedHandler() .SetOrder(ValidateFrontchannelAccessToken.Descriptor.Order + 1_000) .SetType(OpenIddictClientHandlerType.BuiltIn) .Build(); @@ -1327,15 +1338,15 @@ public static partial class OpenIddictClientHandlers throw new ArgumentNullException(nameof(context)); } - if (context.FrontchannelAuthorizationCodePrincipal is not null || - string.IsNullOrEmpty(context.FrontchannelAuthorizationCode)) + if (context.AuthorizationCodePrincipal is not null || + string.IsNullOrEmpty(context.AuthorizationCode)) { return; } var notification = new ValidateTokenContext(context.Transaction) { - Token = context.FrontchannelAuthorizationCode, + Token = context.AuthorizationCode, ValidTokenTypes = { TokenTypeHints.AuthorizationCode } }; @@ -1362,7 +1373,7 @@ public static partial class OpenIddictClientHandlers return; } - context.FrontchannelAuthorizationCodePrincipal = notification.Principal; + context.AuthorizationCodePrincipal = notification.Principal; } } @@ -1376,9 +1387,9 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() + .AddFilter() .UseSingletonHandler() - .SetOrder(ValidateFrontchannelAuthorizationCode.Descriptor.Order + 1_000) + .SetOrder(ValidateAuthorizationCode.Descriptor.Order + 1_000) .SetType(OpenIddictClientHandlerType.BuiltIn) .Build(); @@ -1390,12 +1401,12 @@ public static partial class OpenIddictClientHandlers throw new ArgumentNullException(nameof(context)); } - Debug.Assert(context.FrontchannelStateTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); - + Debug.Assert(context.StateTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); + // Resolve the grant grant and the response type stored in the state token and extract its individual elements. var types = ( - GrantType: context.FrontchannelStateTokenPrincipal.GetClaim(Claims.Private.GrantType), - ResponseTypes: context.FrontchannelStateTokenPrincipal.GetClaim(Claims.Private.ResponseType) + GrantType: context.StateTokenPrincipal.GetClaim(Claims.Private.GrantType), + ResponseTypes: context.StateTokenPrincipal.GetClaim(Claims.Private.ResponseType) !.Split(Separators.Space, StringSplitOptions.RemoveEmptyEntries) .ToImmutableHashSet()); @@ -1426,14 +1437,14 @@ public static partial class OpenIddictClientHandlers // include "openid", which indicates the initial request was an OpenID Connect request. (GrantTypes.AuthorizationCode or GrantTypes.Implicit, ImmutableHashSet set) when set.Contains(ResponseTypes.Code) && - context.FrontchannelStateTokenPrincipal.HasScope(Scopes.OpenId) => (true, true, true), + context.StateTokenPrincipal.HasScope(Scopes.OpenId) => (true, true, true), _ => (false, false, false) }; - (context.ExtractBackchannelRefreshToken, - context.RequireBackchannelRefreshToken, - context.ValidateBackchannelRefreshToken) = types switch + (context.ExtractRefreshToken, + context.RequireRefreshToken, + context.ValidateRefreshToken) = types switch { // A refresh token may be returned as part of token responses, depending on the // policy enforced by the remote authorization server (e.g the "offline_access" @@ -1454,18 +1465,18 @@ public static partial class OpenIddictClientHandlers } /// - /// Contains the logic responsible of attaching the parameters to the backchannel token request, if applicable. + /// Contains the logic responsible of attaching the parameters to the token request, if applicable. /// - public class AttachBackchannelRequestParameters : IOpenIddictClientHandler + public class AttachTokenRequestParameters : IOpenIddictClientHandler { /// /// Gets the default descriptor definition assigned to this handler. /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() - .AddFilter() - .UseSingletonHandler() + .AddFilter() + .AddFilter() + .UseSingletonHandler() .SetOrder(EvaluateValidatedBackchannelTokens.Descriptor.Order + 1_000) .Build(); @@ -1477,13 +1488,13 @@ public static partial class OpenIddictClientHandlers throw new ArgumentNullException(nameof(context)); } - Debug.Assert(context.FrontchannelStateTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); + Debug.Assert(context.StateTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); // Attach a new request instance if none was created already. context.TokenRequest ??= new OpenIddictRequest(); // Attach the grant type selected during the challenge phase. - context.TokenRequest.GrantType = context.FrontchannelStateTokenPrincipal.GetClaim(Claims.Private.GrantType) switch + context.TokenRequest.GrantType = context.StateTokenPrincipal.GetClaim(Claims.Private.GrantType) switch { null => throw new InvalidOperationException(SR.GetResourceString(SR.ID0294)), @@ -1509,9 +1520,9 @@ public static partial class OpenIddictClientHandlers // the redirect_uri from the state token principal and attach them to the request, if available. if (context.TokenRequest.GrantType is GrantTypes.AuthorizationCode) { - context.TokenRequest.Code = context.FrontchannelAuthorizationCode; - context.TokenRequest.CodeVerifier = context.FrontchannelStateTokenPrincipal.GetClaim(Claims.Private.CodeVerifier); - context.TokenRequest.RedirectUri = context.FrontchannelStateTokenPrincipal.GetClaim(Claims.Private.RedirectUri); + context.TokenRequest.Code = context.AuthorizationCode; + context.TokenRequest.CodeVerifier = context.StateTokenPrincipal.GetClaim(Claims.Private.CodeVerifier); + context.TokenRequest.RedirectUri = context.StateTokenPrincipal.GetClaim(Claims.Private.RedirectUri); } return default; @@ -1521,11 +1532,11 @@ public static partial class OpenIddictClientHandlers /// /// Contains the logic responsible of sending the token request, if applicable. /// - public class SendBackchannelRequest : IOpenIddictClientHandler + public class SendTokenRequest : IOpenIddictClientHandler { private readonly OpenIddictClientService _service; - public SendBackchannelRequest(OpenIddictClientService service) + public SendTokenRequest(OpenIddictClientService service) => _service = service; /// @@ -1533,9 +1544,9 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() - .UseSingletonHandler() - .SetOrder(AttachBackchannelRequestParameters.Descriptor.Order + 1_000) + .AddFilter() + .UseSingletonHandler() + .SetOrder(AttachTokenRequestParameters.Descriptor.Order + 1_000) .Build(); /// @@ -1555,16 +1566,16 @@ public static partial class OpenIddictClientHandlers /// /// Contains the logic responsible of rejecting errored token responses. /// - public class ValidateBackchannelErrorParameters : IOpenIddictClientHandler + public class ValidateTokenErrorParameters : IOpenIddictClientHandler { /// /// Gets the default descriptor definition assigned to this handler. /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() - .UseSingletonHandler() - .SetOrder(SendBackchannelRequest.Descriptor.Order + 1_000) + .AddFilter() + .UseSingletonHandler() + .SetOrder(SendTokenRequest.Descriptor.Order + 1_000) .Build(); /// @@ -1601,9 +1612,9 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() + .AddFilter() .UseSingletonHandler() - .SetOrder(ValidateBackchannelErrorParameters.Descriptor.Order + 1_000) + .SetOrder(ValidateTokenErrorParameters.Descriptor.Order + 1_000) .Build(); /// @@ -1628,7 +1639,7 @@ public static partial class OpenIddictClientHandlers false => null }; - context.BackchannelRefreshToken = context.ExtractBackchannelRefreshToken switch + context.RefreshToken = context.ExtractRefreshToken switch { true => context.TokenResponse.RefreshToken, false => null @@ -1661,9 +1672,9 @@ public static partial class OpenIddictClientHandlers throw new ArgumentNullException(nameof(context)); } - if ((context.RequireBackchannelAccessToken && string.IsNullOrEmpty(context.BackchannelAccessToken)) || - (context.RequireBackchannelIdentityToken && string.IsNullOrEmpty(context.BackchannelIdentityToken)) || - (context.RequireBackchannelRefreshToken && string.IsNullOrEmpty(context.BackchannelRefreshToken))) + if ((context.RequireBackchannelAccessToken && string.IsNullOrEmpty(context.BackchannelAccessToken)) || + (context.RequireBackchannelIdentityToken && string.IsNullOrEmpty(context.BackchannelIdentityToken)) || + (context.RequireRefreshToken && string.IsNullOrEmpty(context.RefreshToken))) { context.Reject( error: Errors.MissingToken, @@ -1755,7 +1766,7 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() + .AddFilter() .UseSingletonHandler() .SetOrder(ValidateBackchannelIdentityToken.Descriptor.Order + 1_000) .SetType(OpenIddictClientHandlerType.BuiltIn) @@ -1892,7 +1903,7 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() + .AddFilter() .UseSingletonHandler() .SetOrder(ValidateBackchannelIdentityTokenWellknownClaims.Descriptor.Order + 1_000) .Build(); @@ -1937,7 +1948,7 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() + .AddFilter() .UseSingletonHandler() .SetOrder(ValidateBackchannelIdentityTokenAudience.Descriptor.Order + 1_000) .Build(); @@ -1980,7 +1991,7 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() + .AddFilter() .UseSingletonHandler() .SetOrder(ValidateBackchannelIdentityTokenPresenter.Descriptor.Order + 1_000) .Build(); @@ -1994,11 +2005,11 @@ public static partial class OpenIddictClientHandlers } Debug.Assert(context.BackchannelIdentityTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); - Debug.Assert(context.FrontchannelStateTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); + Debug.Assert(context.StateTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); switch (( BackchannelIdentityTokenNonce: context.BackchannelIdentityTokenPrincipal.GetClaim(Claims.Nonce), - StateTokenNonce: context.FrontchannelStateTokenPrincipal.GetClaim(Claims.Private.Nonce))) + StateTokenNonce: context.StateTokenPrincipal.GetClaim(Claims.Private.Nonce))) { // If no nonce if no present in the state token (e.g because the authorization server doesn't // support OpenID Connect and response_type=code was negotiated), bypass the validation logic. @@ -2043,7 +2054,7 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() + .AddFilter() .UseSingletonHandler() .SetOrder(ValidateBackchannelIdentityTokenNonce.Descriptor.Order + 1_000) .Build(); @@ -2213,15 +2224,15 @@ public static partial class OpenIddictClientHandlers } /// - /// Contains the logic responsible of validating the backchannel refresh token resolved from the context. + /// Contains the logic responsible of validating the refresh token resolved from the context. /// Note: this handler is typically not used for standard-compliant implementations as refresh tokens /// are supposed to be opaque to clients. /// - public class ValidateBackchannelRefreshToken : IOpenIddictClientHandler + public class ValidateRefreshToken : IOpenIddictClientHandler { private readonly IOpenIddictClientDispatcher _dispatcher; - public ValidateBackchannelRefreshToken(IOpenIddictClientDispatcher dispatcher) + public ValidateRefreshToken(IOpenIddictClientDispatcher dispatcher) => _dispatcher = dispatcher; /// @@ -2229,8 +2240,8 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() - .UseScopedHandler() + .AddFilter() + .UseScopedHandler() .SetOrder(ValidateBackchannelAccessToken.Descriptor.Order + 1_000) .SetType(OpenIddictClientHandlerType.BuiltIn) .Build(); @@ -2243,15 +2254,15 @@ public static partial class OpenIddictClientHandlers throw new ArgumentNullException(nameof(context)); } - if (context.BackchannelRefreshTokenPrincipal is not null || - string.IsNullOrEmpty(context.BackchannelRefreshToken)) + if (context.RefreshTokenPrincipal is not null || + string.IsNullOrEmpty(context.RefreshToken)) { return; } var notification = new ValidateTokenContext(context.Transaction) { - Token = context.BackchannelRefreshToken, + Token = context.RefreshToken, ValidTokenTypes = { TokenTypeHints.RefreshToken } }; @@ -2278,7 +2289,455 @@ public static partial class OpenIddictClientHandlers return; } - context.BackchannelRefreshTokenPrincipal = notification.Principal; + context.RefreshTokenPrincipal = notification.Principal; + } + } + + /// + /// Contains the logic responsible of determining whether a userinfo token should be validated. + /// + public class EvaluateValidatedUserinfoToken : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(ValidateRefreshToken.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessAuthenticationContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(context.StateTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); + + // Resolve the grant grant and the response type stored in the state token and extract its individual elements. + var types = ( + GrantType: context.StateTokenPrincipal.GetClaim(Claims.Private.GrantType), + ResponseTypes: context.StateTokenPrincipal.GetClaim(Claims.Private.ResponseType) + !.Split(Separators.Space, StringSplitOptions.RemoveEmptyEntries) + .ToImmutableHashSet()); + + (context.ExtractUserinfoToken, + context.RequireUserinfoToken, + context.ValidateUserinfoToken) = types switch + { + // Information about the authenticated user can be retrieved from the userinfo + // endpoint when a backchannel access token is available. In this case, user data + // will be returned either as a JSON object or as a signed and/or encrypted + // JSON Web Token if the client registration indicates the client supports it. + // + // By default, OpenIddict doesn't require that userinfo-as-JWT responses be used + // but userinfo tokens will be extracted and validated if they are available. + (GrantTypes.AuthorizationCode or GrantTypes.Implicit, ImmutableHashSet set) + when context.StateTokenPrincipal.HasScope(Scopes.OpenId) && + (set.Contains(ResponseTypes.Code) || set.Contains(ResponseTypes.Token)) + => (true, false, true), + + _ => (false, false, false) + }; + + return default; + } + } + + /// + /// Contains the logic responsible of attaching the parameters to the userinfo request, if applicable. + /// + public class AttachUserinfoRequestParameters : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ValidateRefreshToken.Descriptor.Order + 1_000) + .Build(); + + /// + public async ValueTask HandleAsync(ProcessAuthenticationContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + var configuration = await context.Registration.ConfigurationManager.GetConfigurationAsync(default) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0140)); + + // Ensure the issuer resolved from the configuration matches the expected value. + if (configuration.Issuer != context.Issuer) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0307)); + } + + var token = context switch + { + // Note: the backchannel access token (retrieved from the token endpoint) is always preferred to + // the frontchannel access token if available, as it may grant a greater access to user's resources. + { ExtractBackchannelAccessToken: true, BackchannelAccessToken: { Length: > 0 } value } + // If the userinfo endpoint is not available, skip the request. + when configuration.UserinfoEndpoint is not null => value, + + // If the backchannel access token is not available, try to use the frontchannel access token. + { ExtractFrontchannelAccessToken: true, FrontchannelAccessToken: { Length: > 0 } value } + // If the userinfo endpoint is not available, skip the request. + when configuration.UserinfoEndpoint is not null => value, + + // Otherwise, skip the userinfo request. + _ => null + }; + + if (string.IsNullOrEmpty(token)) + { + return; + } + + // Attach a new request instance if none was created already. + context.UserinfoRequest ??= new OpenIddictRequest(); + + // Attach the access token. + context.UserinfoRequest.AccessToken = token; + } + } + + /// + /// Contains the logic responsible of sending the userinfo request, if applicable. + /// + public class SendUserinfoRequest : IOpenIddictClientHandler + { + private readonly OpenIddictClientService _service; + + public SendUserinfoRequest(OpenIddictClientService service) + => _service = service; + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(AttachUserinfoRequestParameters.Descriptor.Order + 1_000) + .Build(); + + /// + public async ValueTask HandleAsync(ProcessAuthenticationContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(context.UserinfoRequest is not null, SR.GetResourceString(SR.ID4008)); + + // Note: userinfo responses can be of two types: + // - application/json responses containing a JSON object listing the user claims as-is. + // - application/jwt responses containing a signed/encrypted JSON Web Token containing the user claims. + + (context.UserinfoResponse, (context.UserinfoTokenPrincipal, context.UserinfoToken)) = + await _service.SendUserinfoRequestAsync(context.Registration, context.UserinfoRequest); + } + } + + /// + /// Contains the logic responsible of rejecting errored userinfo responses. + /// + public class ValidateUserinfoErrorParameters : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(SendUserinfoRequest.Descriptor.Order + 1_000) + .Build(); + + /// + public ValueTask HandleAsync(ProcessAuthenticationContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(context.UserinfoResponse is not null, SR.GetResourceString(SR.ID4007)); + + if (!string.IsNullOrEmpty(context.UserinfoResponse.Error)) + { + context.Reject( + error: context.UserinfoResponse.Error, + description: context.UserinfoResponse.ErrorDescription, + uri: context.UserinfoResponse.ErrorUri); + + return default; + } + + return default; + } + } + + /// + /// Contains the logic responsible of rejecting authentication demands that lack the required userinfo token. + /// + public class ValidateRequiredUserinfoToken : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ValidateUserinfoErrorParameters.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessAuthenticationContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (context.RequireUserinfoToken && string.IsNullOrEmpty(context.UserinfoToken)) + { + context.Reject( + error: Errors.MissingToken, + description: SR.GetResourceString(SR.ID2000), + uri: SR.FormatID8000(SR.ID2000)); + + return default; + } + + return default; + } + } + + /// + /// Contains the logic responsible of validating the userinfo token resolved from the context. + /// + public class ValidateUserinfoToken : IOpenIddictClientHandler + { + private readonly IOpenIddictClientDispatcher _dispatcher; + + public ValidateUserinfoToken(IOpenIddictClientDispatcher dispatcher) + => _dispatcher = dispatcher; + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseScopedHandler() + .SetOrder(ValidateRequiredUserinfoToken.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public async ValueTask HandleAsync(ProcessAuthenticationContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (context.UserinfoTokenPrincipal is not null || string.IsNullOrEmpty(context.UserinfoToken)) + { + return; + } + + var notification = new ValidateTokenContext(context.Transaction) + { + Token = context.UserinfoToken, + ValidTokenTypes = { TokenTypeHints.UserinfoToken } + }; + + 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.UserinfoTokenPrincipal = notification.Principal; + } + } + + /// + /// Contains the logic responsible of validating the well-known claims contained in the userinfo token. + /// + public class ValidateUserinfoTokenWellknownClaims : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .AddFilter() + .UseSingletonHandler() + .SetOrder(ValidateUserinfoToken.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessAuthenticationContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(context.StateTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); + Debug.Assert(context.UserinfoTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); + + // The OpenIddict client is expected to be used with standard OpenID Connect userinfo endpoints + // but must also support non-standard implementations, that are common with OAuth 2.0-only servers. + // + // As such, protocol requirements are only enforced if an OpenID Connect request was initially sent. + if (context.StateTokenPrincipal.HasScope(Scopes.OpenId)) + { + foreach (var group in context.UserinfoTokenPrincipal.Claims + .GroupBy(claim => claim.Type) + .ToDictionary(group => group.Key, group => group.ToList())) + { + if (ValidateClaimGroup(group)) + { + continue; + } + + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2131(group.Key), + uri: SR.FormatID8000(SR.ID2131)); + + return default; + } + } + + return default; + + static bool ValidateClaimGroup(KeyValuePair> claims) => claims switch + { + // The following JWT claims MUST be represented as unique strings. + { + Key: Claims.Subject, + Value: List values + } => values.Count is 1 && values[0].ValueType is ClaimValueTypes.String, + + // Claims that are not in the well-known list can be of any type. + _ => true + }; + } + } + + /// + /// Contains the logic responsible of validating the subject claim contained in the userinfo token. + /// + public class ValidateUserinfoTokenWellknownSubject : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .AddFilter() + .UseSingletonHandler() + .SetOrder(ValidateUserinfoTokenWellknownClaims.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessAuthenticationContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(context.StateTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); + Debug.Assert(context.UserinfoTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); + + // The OpenIddict client is expected to be used with standard OpenID Connect userinfo endpoints + // but must also support non-standard implementations, that are common with OAuth 2.0-only servers. + // + // As such, protocol requirements are only enforced if an OpenID Connect request was initially sent. + if (context.StateTokenPrincipal.HasScope(Scopes.OpenId)) + { + // Standard OpenID Connect userinfo responses/tokens MUST contain a "sub" claim. For more + // information, see https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse. + if (!context.UserinfoTokenPrincipal.HasClaim(Claims.Subject)) + { + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2132(Claims.Subject), + uri: SR.FormatID8000(SR.ID2132)); + + return default; + } + + // The "sub" claim returned as part of the userinfo response/token MUST exactly match the value + // returned in the frontchannel identity token, if one was returned. For more information, + // see https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse. + if (context.FrontchannelIdentityTokenPrincipal is not null && !string.Equals( + context.FrontchannelIdentityTokenPrincipal.GetClaim(Claims.Subject), + context.UserinfoTokenPrincipal.GetClaim(Claims.Subject), StringComparison.Ordinal)) + { + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2133(Claims.Subject), + uri: SR.FormatID8000(SR.ID2133)); + + return default; + } + + // The "sub" claim returned as part of the userinfo response/token MUST exactly match the value + // returned in the frontchannel identity token, if one was returned. For more information, + // see https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse. + if (context.BackchannelIdentityTokenPrincipal is not null && !string.Equals( + context.BackchannelIdentityTokenPrincipal.GetClaim(Claims.Subject), + context.UserinfoTokenPrincipal.GetClaim(Claims.Subject), StringComparison.Ordinal)) + { + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2133(Claims.Subject), + uri: SR.FormatID8000(SR.ID2133)); + + return default; + } + } + + return default; } } diff --git a/src/OpenIddict.Client/OpenIddictClientRegistration.cs b/src/OpenIddict.Client/OpenIddictClientRegistration.cs index 4dfa7325..05e7cf99 100644 --- a/src/OpenIddict.Client/OpenIddictClientRegistration.cs +++ b/src/OpenIddict.Client/OpenIddictClientRegistration.cs @@ -99,6 +99,7 @@ public class OpenIddictClientRegistration /// public TokenValidationParameters TokenValidationParameters { get; } = new TokenValidationParameters { + AuthenticationType = TokenValidationParameters.DefaultAuthenticationType, ClockSkew = TimeSpan.Zero, NameClaimType = Claims.Name, RoleClaimType = Claims.Role, diff --git a/src/OpenIddict.Client/OpenIddictClientService.cs b/src/OpenIddict.Client/OpenIddictClientService.cs index 0383dd44..8776a13a 100644 --- a/src/OpenIddict.Client/OpenIddictClientService.cs +++ b/src/OpenIddict.Client/OpenIddictClientService.cs @@ -5,6 +5,7 @@ */ using System.Diagnostics; +using System.Security.Claims; using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.Tokens; @@ -461,4 +462,157 @@ public class OpenIddictClientService } } } + + /// + /// Sends the userinfo request and retrieves the corresponding response. + /// + /// The client registration. + /// The userinfo request. + /// The that can be used to abort the operation. + /// The response and the principal extracted from the userinfo response or the userinfo token. + public async ValueTask<(OpenIddictResponse Response, (ClaimsPrincipal? Principal, string? Token))> SendUserinfoRequestAsync( + OpenIddictClientRegistration registration, OpenIddictRequest request, CancellationToken cancellationToken = default) + { + if (registration is null) + { + throw new ArgumentNullException(nameof(registration)); + } + + var configuration = await registration.ConfigurationManager.GetConfigurationAsync(default) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0140)); + + if (configuration.UserinfoEndpoint is not { IsAbsoluteUri: true } || + !configuration.UserinfoEndpoint.IsWellFormedOriginalString()) + { + throw new InvalidOperationException(SR.FormatID0301(Metadata.UserinfoEndpoint)); + } + + 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(); + var factory = scope.ServiceProvider.GetRequiredService(); + var transaction = await factory.CreateTransactionAsync(); + + request = await PrepareUserinfoRequestAsync(); + request = await ApplyUserinfoRequestAsync(); + + var (response, token) = await ExtractUserinfoResponseAsync(); + + return await HandleUserinfoResponseAsync(); + + async ValueTask PrepareUserinfoRequestAsync() + { + var context = new PrepareUserinfoRequestContext(transaction) + { + Address = configuration.UserinfoEndpoint, + Issuer = registration.Issuer, + Registration = registration, + Request = request + }; + + await dispatcher.DispatchAsync(context); + + if (context.IsRejected) + { + throw new OpenIddictExceptions.GenericException( + SR.FormatID0152(context.Error, context.ErrorDescription, context.ErrorUri), + context.Error, context.ErrorDescription, context.ErrorUri); + } + + return context.Request; + } + + async ValueTask ApplyUserinfoRequestAsync() + { + var context = new ApplyUserinfoRequestContext(transaction) + { + Address = configuration.UserinfoEndpoint, + Issuer = registration.Issuer, + Registration = registration, + Request = request + }; + + await dispatcher.DispatchAsync(context); + + if (context.IsRejected) + { + throw new OpenIddictExceptions.GenericException( + SR.FormatID0153(context.Error, context.ErrorDescription, context.ErrorUri), + context.Error, context.ErrorDescription, context.ErrorUri); + } + + return context.Request; + } + + async ValueTask<(OpenIddictResponse, string?)> ExtractUserinfoResponseAsync() + { + var context = new ExtractUserinfoResponseContext(transaction) + { + Address = configuration.UserinfoEndpoint, + Issuer = registration.Issuer, + Registration = registration, + Request = request + }; + + await dispatcher.DispatchAsync(context); + + if (context.IsRejected) + { + throw new OpenIddictExceptions.GenericException( + SR.FormatID0154(context.Error, context.ErrorDescription, context.ErrorUri), + context.Error, context.ErrorDescription, context.ErrorUri); + } + + Debug.Assert(context.Response is not null, SR.GetResourceString(SR.ID4007)); + + return (context.Response, context.UserinfoToken); + } + + async ValueTask<(OpenIddictResponse, (ClaimsPrincipal?, string?))> HandleUserinfoResponseAsync() + { + var context = new HandleUserinfoResponseContext(transaction) + { + Address = configuration.UserinfoEndpoint, + Issuer = registration.Issuer, + Registration = registration, + Request = request, + Response = response, + UserinfoToken = token + }; + + await dispatcher.DispatchAsync(context); + + if (context.IsRejected) + { + throw new OpenIddictExceptions.GenericException( + SR.FormatID0155(context.Error, context.ErrorDescription, context.ErrorUri), + context.Error, context.ErrorDescription, context.ErrorUri); + } + + return (context.Response, (context.Principal, context.UserinfoToken)); + } + } + + finally + { + if (scope is IAsyncDisposable disposable) + { + await disposable.DisposeAsync(); + } + + else + { + scope.Dispose(); + } + } + } } diff --git a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandler.cs b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandler.cs index 10cc5620..fcb59933 100644 --- a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandler.cs +++ b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandler.cs @@ -200,8 +200,6 @@ public class OpenIddictServerAspNetCoreHandler : AuthenticationHandler true,