diff --git a/gen/OpenIddict.Client.WebIntegration.Generators/OpenIddictClientWebIntegrationGenerator.cs b/gen/OpenIddict.Client.WebIntegration.Generators/OpenIddictClientWebIntegrationGenerator.cs index 28bc8224..fb642c88 100644 --- a/gen/OpenIddict.Client.WebIntegration.Generators/OpenIddictClientWebIntegrationGenerator.cs +++ b/gen/OpenIddict.Client.WebIntegration.Generators/OpenIddictClientWebIntegrationGenerator.cs @@ -225,6 +225,16 @@ public sealed partial class OpenIddictClientWebIntegrationBuilder return Set(registration => registration.Scopes.UnionWith(scopes)); } + /// + /// Disables pushed authorization requests for this client registration. When pushed authorization + /// requests are disabled, PAR is not used by the OpenIddict client, even if the remote authorization + /// server exposes a pushed authorization endpoint. If the authorization server requires using PAR, + /// an exception is automatically thrown when starting an interactive authentication challenge. + /// + /// The instance. + public {{ provider.name }} DisablePushedAuthorizationRequests() + => Set(registration => registration.DisablePushedAuthorizationRequests = true); + /// /// Sets the issuer that will be attached to the /// instances created by the OpenIddict client stack for this provider. diff --git a/sandbox/OpenIddict.Sandbox.AspNet.Server/Controllers/AuthorizationController.cs b/sandbox/OpenIddict.Sandbox.AspNet.Server/Controllers/AuthorizationController.cs index 36c3a9c3..4e5e5bdf 100644 --- a/sandbox/OpenIddict.Sandbox.AspNet.Server/Controllers/AuthorizationController.cs +++ b/sandbox/OpenIddict.Sandbox.AspNet.Server/Controllers/AuthorizationController.cs @@ -48,6 +48,11 @@ public class AuthorizationController : Controller [HttpGet, Route("~/connect/authorize")] public async Task Authorize() { + // Note: the request object contains all the parameters specified in the query string or request form + // or initially sent to the pushed authorization endpoint for a PAR-enabled authorization flow. + // As such, the data contained in this object MUST NOT be serialized or returned unprotected to the + // user agent (e.g as HTML hidden input fields). If only the query string or request form parameters + // need to be resolved, the Request.QueryString and Request.Form collections must be used instead. var context = HttpContext.GetOwinContext(); var request = context.GetOpenIddictServerRequest() ?? throw new InvalidOperationException("The OpenID Connect request cannot be retrieved."); @@ -213,6 +218,11 @@ public class AuthorizationController : Controller [HttpPost, Route("~/connect/authorize"), ValidateAntiForgeryToken] public async Task Accept() { + // Note: the request object contains all the parameters specified in the query string or request form + // (or initially sent to the pushed authorization endpoint for a PAR-enabled authorization flow). + // As such, the data contained in this object MUST NOT be serialized or returned unprotected to the + // user agent (e.g as HTML hidden input fields). If only the query string or request form parameters + // need to be resolved, the Request.QueryString and Request.Form collections must be used instead. var context = HttpContext.GetOwinContext(); var request = context.GetOpenIddictServerRequest() ?? throw new InvalidOperationException("The OpenID Connect request cannot be retrieved."); diff --git a/sandbox/OpenIddict.Sandbox.AspNet.Server/Startup.cs b/sandbox/OpenIddict.Sandbox.AspNet.Server/Startup.cs index ad53f978..c1b2ea00 100644 --- a/sandbox/OpenIddict.Sandbox.AspNet.Server/Startup.cs +++ b/sandbox/OpenIddict.Sandbox.AspNet.Server/Startup.cs @@ -87,6 +87,7 @@ public class Startup .SetEndSessionEndpointUris("connect/endsession") .SetEndUserVerificationEndpointUris("connect/verify") .SetIntrospectionEndpointUris("connect/introspect") + .SetPushedAuthorizationEndpointUris("connect/par") .SetTokenEndpointUris("connect/token") .SetUserInfoEndpointUris("connect/userinfo"); @@ -103,9 +104,6 @@ public class Startup options.AddDevelopmentEncryptionCertificate() .AddDevelopmentSigningCertificate(); - // Force client applications to use Proof Key for Code Exchange (PKCE). - options.RequireProofKeyForCodeExchange(); - // Register the OWIN host and configure the OWIN-specific options. options.UseOwin() .EnableAuthorizationEndpointPassthrough() @@ -215,6 +213,7 @@ public class Startup { Permissions.Endpoints.Authorization, Permissions.Endpoints.EndSession, + Permissions.Endpoints.PushedAuthorization, Permissions.Endpoints.Token, Permissions.GrantTypes.AuthorizationCode, Permissions.GrantTypes.RefreshToken, @@ -226,7 +225,8 @@ public class Startup }, Requirements = { - Requirements.Features.ProofKeyForCodeExchange + Requirements.Features.ProofKeyForCodeExchange, + Requirements.Features.PushedAuthorizationRequests } }); } diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Controllers/AuthorizationController.cs b/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Controllers/AuthorizationController.cs index 0b1f2308..d3ee0f81 100644 --- a/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Controllers/AuthorizationController.cs +++ b/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Controllers/AuthorizationController.cs @@ -58,6 +58,11 @@ public class AuthorizationController : Controller [IgnoreAntiforgeryToken] public async Task Authorize() { + // Note: the request object contains all the parameters specified in the query string or request form + // or initially sent to the pushed authorization endpoint for a PAR-enabled authorization flow. + // As such, the data contained in this object MUST NOT be serialized or returned unprotected to the + // user agent (e.g as HTML hidden input fields). If only the query string or request form parameters + // need to be resolved, the Request.Query and Request.Form collections must be used instead. var request = HttpContext.GetOpenIddictServerRequest() ?? throw new InvalidOperationException("The OpenID Connect request cannot be retrieved."); @@ -234,6 +239,11 @@ public class AuthorizationController : Controller [HttpPost("~/connect/authorize"), ValidateAntiForgeryToken] public async Task Accept() { + // Note: the request object contains all the parameters specified in the query string or request form + // (or initially sent to the pushed authorization endpoint for a PAR-enabled authorization flow). + // As such, the data contained in this object MUST NOT be serialized or returned unprotected to the + // user agent (e.g as HTML hidden input fields). If only the query string or request form parameters + // need to be resolved, the Request.Query and Request.Form collections must be used instead. var request = HttpContext.GetOpenIddictServerRequest() ?? throw new InvalidOperationException("The OpenID Connect request cannot be retrieved."); diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Startup.cs b/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Startup.cs index 040a48c5..a73a7809 100644 --- a/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Startup.cs +++ b/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Startup.cs @@ -105,6 +105,7 @@ public class Startup .SetEndSessionEndpointUris("connect/endsession") .SetEndUserVerificationEndpointUris("connect/verify") .SetIntrospectionEndpointUris("connect/introspect") + .SetPushedAuthorizationEndpointUris("connect/par") .SetRevocationEndpointUris("connect/revoke") .SetTokenEndpointUris("connect/token") .SetUserInfoEndpointUris("connect/userinfo"); @@ -125,9 +126,6 @@ public class Startup options.AddDevelopmentEncryptionCertificate() .AddDevelopmentSigningCertificate(); - // Force client applications to use Proof Key for Code Exchange (PKCE). - options.RequireProofKeyForCodeExchange(); - // Register the ASP.NET Core host and configure the ASP.NET Core-specific options. options.UseAspNetCore() .EnableStatusCodePagesIntegration() diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Worker.cs b/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Worker.cs index f4d44f2f..7d79458c 100644 --- a/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Worker.cs +++ b/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Worker.cs @@ -77,6 +77,7 @@ public class Worker : IHostedService Permissions.Endpoints.DeviceAuthorization, Permissions.Endpoints.Introspection, Permissions.Endpoints.EndSession, + Permissions.Endpoints.PushedAuthorization, Permissions.Endpoints.Revocation, Permissions.Endpoints.Token, Permissions.GrantTypes.AuthorizationCode, @@ -98,7 +99,8 @@ public class Worker : IHostedService }, Requirements = { - Requirements.Features.ProofKeyForCodeExchange + Requirements.Features.ProofKeyForCodeExchange, + Requirements.Features.PushedAuthorizationRequests } }); } @@ -128,6 +130,7 @@ public class Worker : IHostedService { Permissions.Endpoints.Authorization, Permissions.Endpoints.EndSession, + Permissions.Endpoints.PushedAuthorization, Permissions.Endpoints.Token, Permissions.GrantTypes.AuthorizationCode, Permissions.GrantTypes.RefreshToken, @@ -139,7 +142,8 @@ public class Worker : IHostedService }, Requirements = { - Requirements.Features.ProofKeyForCodeExchange + Requirements.Features.ProofKeyForCodeExchange, + Requirements.Features.PushedAuthorizationRequests } }); } @@ -189,6 +193,7 @@ public class Worker : IHostedService { Permissions.Endpoints.Authorization, Permissions.Endpoints.EndSession, + Permissions.Endpoints.PushedAuthorization, Permissions.Endpoints.Token, Permissions.GrantTypes.AuthorizationCode, Permissions.GrantTypes.RefreshToken, @@ -200,7 +205,8 @@ public class Worker : IHostedService }, Requirements = { - Requirements.Features.ProofKeyForCodeExchange + Requirements.Features.ProofKeyForCodeExchange, + Requirements.Features.PushedAuthorizationRequests } }); } @@ -230,6 +236,7 @@ public class Worker : IHostedService { Permissions.Endpoints.Authorization, Permissions.Endpoints.EndSession, + Permissions.Endpoints.PushedAuthorization, Permissions.Endpoints.Token, Permissions.GrantTypes.AuthorizationCode, Permissions.GrantTypes.RefreshToken, @@ -241,7 +248,8 @@ public class Worker : IHostedService }, Requirements = { - Requirements.Features.ProofKeyForCodeExchange + Requirements.Features.ProofKeyForCodeExchange, + Requirements.Features.PushedAuthorizationRequests } }); } @@ -271,6 +279,7 @@ public class Worker : IHostedService { Permissions.Endpoints.Authorization, Permissions.Endpoints.EndSession, + Permissions.Endpoints.PushedAuthorization, Permissions.Endpoints.Token, Permissions.GrantTypes.AuthorizationCode, Permissions.GrantTypes.RefreshToken, @@ -282,7 +291,8 @@ public class Worker : IHostedService }, Requirements = { - Requirements.Features.ProofKeyForCodeExchange + Requirements.Features.ProofKeyForCodeExchange, + Requirements.Features.PushedAuthorizationRequests } }); } diff --git a/src/OpenIddict.Abstractions/OpenIddictConstants.cs b/src/OpenIddict.Abstractions/OpenIddictConstants.cs index ed71631b..5c2699b3 100644 --- a/src/OpenIddict.Abstractions/OpenIddictConstants.cs +++ b/src/OpenIddict.Abstractions/OpenIddictConstants.cs @@ -145,6 +145,9 @@ public static class OpenIddictConstants public const string RedirectUri = "oi_reduri"; public const string RefreshTokenLifetime = "oi_reft_lft"; public const string RegistrationId = "oi_reg_id"; + public const string RequestParameters = "oi_req_prms"; + public const string RequestTokenLifetime = "oi_reqt_lft"; + public const string RequestTokenType = "oi_reqt_typ"; public const string Resource = "oi_rsrc"; public const string ResponseType = "oi_rsp_typ"; public const string SigningAlgorithm = "oi_sign_alg"; @@ -264,6 +267,7 @@ public static class OpenIddictConstants public const string AuthorizationCode = "oi_auc+jwt"; public const string DeviceCode = "oi_dvc+jwt"; public const string RefreshToken = "oi_reft+jwt"; + public const string RequestToken = "oi_reqt+jwt"; public const string StateToken = "oi_stet+jwt"; public const string UserCode = "oi_usrc+jwt"; } @@ -296,11 +300,14 @@ public static class OpenIddictConstants public const string OpPolicyUri = "op_policy_uri"; public const string OpTosUri = "op_tos_uri"; public const string PromptValuesSupported = "prompt_values_supported"; + public const string PushedAuthorizationRequestEndpoint = "pushed_authorization_request_endpoint"; + public const string PushedAuthorizationRequestEndpointAuthMethodsSupported = "pushed_authorization_request_endpoint_auth_methods_supported"; public const string RequestObjectEncryptionAlgValuesSupported = "request_object_encryption_alg_values_supported"; public const string RequestObjectEncryptionEncValuesSupported = "request_object_encryption_enc_values_supported"; public const string RequestObjectSigningAlgValuesSupported = "request_object_signing_alg_values_supported"; public const string RequestParameterSupported = "request_parameter_supported"; public const string RequestUriParameterSupported = "request_uri_parameter_supported"; + public const string RequirePushedAuthorizationRequests = "require_pushed_authorization_requests"; public const string RequireRequestUriRegistration = "require_request_uri_registration"; public const string ResponseModesSupported = "response_modes_supported"; public const string ResponseTypesSupported = "response_types_supported"; @@ -362,6 +369,7 @@ public static class OpenIddictConstants public const string RefreshToken = "refresh_token"; public const string Registration = "registration"; public const string Request = "request"; + [Obsolete("This property is obsolete and will be removed in a future version.")] public const string RequestId = "request_id"; public const string RequestUri = "request_uri"; public const string Resource = "resource"; @@ -387,6 +395,7 @@ public static class OpenIddictConstants public const string DeviceAuthorization = "ept:device_authorization"; public const string EndSession = "ept:end_session"; public const string Introspection = "ept:introspection"; + public const string PushedAuthorization = "ept:pushed_authorization"; public const string Revocation = "ept:revocation"; public const string Token = "ept:token"; } @@ -445,11 +454,30 @@ public static class OpenIddictConstants public const string Destinations = ".destinations"; } + public static class RequestTokenTypes + { + public static class Private + { + public const string CachedAuthorizationRequest = "cached_authorization_request"; + public const string CachedEndSessionRequest = "cached_end_session_request"; + public const string PushedAuthorizationRequest = "pushed_authorization_request"; + } + } + + public static class RequestUris + { + public static class Prefixes + { + public const string Generic = "urn:ietf:params:oauth:request_uri:"; + } + } + public static class Requirements { public static class Features { public const string ProofKeyForCodeExchange = "ft:pkce"; + public const string PushedAuthorizationRequests = "ft:par"; } public static class Prefixes @@ -518,6 +546,7 @@ public static class OpenIddictConstants public const string DeviceCode = "tkn_lft:dvc"; public const string IdentityToken = "tkn_lft:idt"; public const string RefreshToken = "tkn_lft:reft"; + public const string RequestToken = "tkn_lft:reqt"; public const string UserCode = "tkn_lft:usrc"; } } @@ -566,6 +595,11 @@ public static class OpenIddictConstants public const string StateToken = "state_token"; public const string UserInfoToken = "userinfo_token"; public const string UserCode = "user_code"; + + public static class Private + { + public const string RequestToken = "request_token"; + } } public static class TokenTypes diff --git a/src/OpenIddict.Abstractions/OpenIddictResources.resx b/src/OpenIddict.Abstractions/OpenIddictResources.resx index 25987cc0..d1659638 100644 --- a/src/OpenIddict.Abstractions/OpenIddictResources.resx +++ b/src/OpenIddict.Abstractions/OpenIddictResources.resx @@ -152,11 +152,11 @@ When implementing custom token deserialization, a 'oi_tkn_typ' claim containing Make sure that 'ClaimsPrincipal.Identity' is not null. - The specified principal contains an authenticated identity, which is not valid when the sign-in operation is triggered from the device authorization endpoint. + The specified principal contains an authenticated identity, which is not valid when the sign-in operation is triggered from the device authorization or pushed authorization endpoints. Make sure that 'ClaimsPrincipal.Identity.AuthenticationType' is null and that 'ClaimsPrincipal.Identity.IsAuthenticated' returns 'false'. - The specified principal contains a subject claim, which is not valid when the sign-in operation is triggered from the device authorization endpoint. + The specified principal contains a subject claim, which is not valid when the sign-in operation is triggered from the device authorization or pushed authorization endpoints. The specified principal doesn't contain a valid/authenticated identity. @@ -218,8 +218,8 @@ Alternatively, create a class implementing 'IOpenIddictServerHandler<HandleAu To apply authorization responses, create a class implementing 'IOpenIddictServerHandler<ApplyAuthorizationResponseContext>' and register it using 'services.AddOpenIddict().AddServer().AddEventHandler()'. - The device request was not correctly extracted. -To extract device requests, create a class implementing 'IOpenIddictServerHandler<ExtractDeviceAuthorizationRequestContext>' and register it using 'services.AddOpenIddict().AddServer().AddEventHandler()'. + The device authorization request was not correctly extracted. +To extract device authorization requests, create a class implementing 'IOpenIddictServerHandler<ExtractDeviceAuthorizationRequestContext>' and register it using 'services.AddOpenIddict().AddServer().AddEventHandler()'. The client application details cannot be found in the database. @@ -416,7 +416,7 @@ To use key rollover, register both the new certificate and the old one in the cr No custom authorization request validation handler was found. When enabling the degraded mode, a custom 'IOpenIddictServerHandler<ValidateAuthorizationRequestContext>' must be implemented to validate authorization requests (e.g to ensure the client_id and redirect_uri are valid). - No custom device request validation handler was found. When enabling the degraded mode, a custom 'IOpenIddictServerHandler<ValidateDeviceAuthorizationRequestContext>' (or 'IOpenIddictServerHandler<ProcessAuthenticationContext>') must be implemented to validate device requests (e.g to ensure the client_id and client_secret are valid). + No custom device authorization request validation handler was found. When enabling the degraded mode, a custom 'IOpenIddictServerHandler<ValidateDeviceAuthorizationRequestContext>' (or 'IOpenIddictServerHandler<ProcessAuthenticationContext>') must be implemented to validate device authorization requests (e.g to ensure the client_id and client_secret are valid). No custom introspection request validation handler was found. When enabling the degraded mode, a custom 'IOpenIddictServerHandler<ValidateIntrospectionRequestContext>' (or 'IOpenIddictServerHandler<ProcessAuthenticationContext>') must be implemented to validate introspection requests (e.g to ensure the client_id and client_secret are valid). @@ -501,12 +501,15 @@ This may indicate that the event handler responsible for processing OpenID Conne A distributed cache instance must be registered when enabling request caching. To register the default in-memory distributed cache implementation, reference the 'Microsoft.Extensions.Caching.Memory' package and call 'services.AddDistributedMemoryCache()' from 'ConfigureServices'. + This resource is no longer used and will be removed in a future version. The authorization request payload is malformed. + This resource is no longer used and will be removed in a future version. The end session request payload is malformed. + This resource is no longer used and will be removed in a future version. The OpenIddict OWIN server handler cannot be used as an active authentication handler. @@ -1707,9 +1710,50 @@ To apply post-logout redirection responses, create a class implementing 'IOpenId A token must be specified when using revocation. + + The authorization server requires using pushed authorization requests. Consider setting 'OpenIddictClientRegistration.DisablePushedAuthorizationRequests' to false to allow the OpenIddict client to use pushed authorization requests. + + + An error occurred while preparing the device authorization request. + Error: {0} + Error description: {1} + Error URI: {2} + + + An error occurred while sending the device authorization request. + Error: {0} + Error description: {1} + Error URI: {2} + + + An error occurred while extracting the device authorization response. + Error: {0} + Error description: {1} + Error URI: {2} + + + An error occurred while handling the device authorization response. + Error: {0} + Error description: {1} + Error URI: {2} + + + Authorization request caching and end session request caching cannot be used when disabling token storage. + + + No custom pushed authorization request validation handler was found. When enabling the degraded mode, a custom 'IOpenIddictServerHandler<ValidatePushedAuthorizationRequestContext>' (or 'IOpenIddictServerHandler<ProcessAuthenticationContext>') must be implemented to validate pushed authorization requests (e.g to ensure the client_id and client_secret are valid). + The VK ID integration requires sending the device identifier to the token and revocation endpoints. For that, attach a ".device_id" authentication property containing the device identifier returned by the authorization endpoint. + + The pushed authorization request was not correctly extracted. +To extract pushed authorization requests, create a class implementing 'IOpenIddictServerHandler<ExtractPushedAuthorizationRequestContext>' and register it using 'services.AddOpenIddict().AddServer().AddEventHandler()'. + + + The pushed authorization response was not correctly applied. +To apply pushed authorization responses, create a class implementing 'IOpenIddictServerHandler<ApplyPushedAuthorizationResponseContext>' and register it using 'services.AddOpenIddict().AddServer().AddEventHandler()'. + The security token is missing. @@ -2134,7 +2178,7 @@ To apply post-logout redirection responses, create a class implementing 'IOpenId The client application is not allowed to use the specified identity token hint. - The specified state token is not suitable for the requested operation. + The specified '{0}' parameter is not suitable for the requested operation. An unsupported content encoding was returned by the remote server. @@ -2238,6 +2282,27 @@ To apply post-logout redirection responses, create a class implementing 'IOpenId The introspection response indicates the token is no longer valid. + + The '{0}' parameter must be attached as a regular OAuth 2.0 parameter when using a request object or pushed authorization requests. + + + The '{0}' parameter doesn't match the value specified in the request object or pushed authorization request. + + + The pushed authorization request was rejected by the remote server. + + + The mandatory '{0}' parameter couldn't be found in the pushed authorization response. + + + The '{0}' parameter returned in the pushed authorization response is not valid absolute URI. + + + A '{0}' obtained from the pushed authorization request endpoint is required for this client application. + + + This client application is not allowed to use the authorization endpoint. + The '{0}' parameter shouldn't be null or empty at this point. @@ -2424,19 +2489,19 @@ The principal used to create the token contained the following claims: {Claims}. The request URI matched a server endpoint: {Endpoint}. - The device request was successfully extracted: {Request}. + The device authorization request was successfully extracted: {Request}. - The device request was successfully validated. + The device authorization request was successfully validated. - The device request was rejected because invalid scopes were specified: {Scopes}. + The device authorization request was rejected because invalid scopes were specified: {Scopes}. - The device request was rejected because the application '{ClientId}' was not allowed to use the device authorization endpoint. + The device authorization request was rejected because the application '{ClientId}' was not allowed to use the device authorization endpoint. - The device request was rejected because the application '{ClientId}' was not allowed to use the scope {Scope}. + The device authorization request was rejected because the application '{ClientId}' was not allowed to use the scope {Scope}. The verification request was successfully extracted: {Request}. @@ -2562,13 +2627,13 @@ The principal used to create the token contained the following claims: {Claims}. The revocation request was rejected because the received token was of an unsupported type. - The device request was rejected because the application '{ClientId}' was not allowed to use the device authorization flow. + The device authorization request was rejected because the application '{ClientId}' was not allowed to use the device authorization flow. The revocation request was rejected because the access token was issued to a different client or for another resource server. - The device request was rejected because the application '{ClientId}' was not allowed to request the '{Scope}' scope. + The device authorization request was rejected because the application '{ClientId}' was not allowed to request the '{Scope}' scope. The revocation request was rejected because the refresh token was issued to a different client. @@ -2901,6 +2966,96 @@ This may indicate that the hashed entry is corrupted or malformed. The authorization request was rejected because an unsupported prompt parameter was specified. + + The pushed authorization request was rejected by the remote authorization server: {Response}. + + + The pushed authorization request was successfully sent to {Uri}: {Request}. + + + The pushed authorization response returned by {Uri} was successfully extracted: {Response}. + + + The pushed authorization request was successfully extracted: {Request}. + + + The pushed authorization request was successfully validated. + + + The pushed authorization request was rejected because it contained an unsupported parameter: {Parameter}. + + + The pushed authorization request was rejected because the mandatory '{Parameter}' parameter was missing. + + + The pushed authorization request was rejected because the '{Parameter}' parameter wasn't a valid absolute URI: {RedirectUri}. + + + The pushed authorization request was rejected because the '{Parameter}' contained a URI fragment: {RedirectUri}. + + + The pushed authorization request was rejected because the '{ResponseType}' response type is not supported. + + + The pushed authorization request was rejected because the 'response_type'/'response_mode' combination was invalid: {ResponseType} ; {ResponseMode}. + + + The pushed authorization request was rejected because the '{ResponseMode}' response mode is not supported. + + + The pushed authorization request was rejected because the '{Scope}' scope was missing. + + + The pushed authorization request was rejected because an invalid prompt combination was specified. + + + The pushed authorization request was rejected because the specified code challenge method was not supported. + + + The pushed authorization request was rejected because the response type was not compatible with 'code_challenge'/'code_challenge_method'. + + + The pushed authorization request was rejected because the specified response type was not compatible with PKCE. + + + The pushed authorization request was rejected because the confidential application '{ClientId}' was not allowed to retrieve an access token from the authorization endpoint. + + + The pushed authorization request was rejected because the redirect_uri was invalid: '{RedirectUri}'. + + + The authentication request was rejected because invalid scopes were specified: {Scopes}. + + + The pushed authorization request was rejected because the application '{ClientId}' was not allowed to use the pushed authorization endpoint. + + + The pushed authorization request was rejected because the application '{ClientId}' was not allowed to use the authorization code flow. + + + The pushed authorization request was rejected because the application '{ClientId}' was not allowed to use the implicit flow. + + + The pushed authorization request was rejected because the application '{ClientId}' was not allowed to use the hybrid flow. + + + The pushed authorization request was rejected because the application '{ClientId}' was not allowed to use the '{Scope}' scope. + + + The pushed authorization request was rejected because the '{Parameter}' contained a forbidden parameter: {Name}. + + + The pushed authorization request was rejected because the '{ResponseType}' response type is not a valid combination. + + + The pushed authorization request was rejected because an unsupported prompt parameter was specified. + + + The pushed authorization request was rejected because the application '{ClientId}' was not allowed to use the '{ResponseType}' response type. + + + The pushed authorization request was rejected because the identity token used as a hint was issued to a different client. + https://documentation.openiddict.com/errors/{0} diff --git a/src/OpenIddict.Abstractions/Primitives/OpenIddictConfiguration.cs b/src/OpenIddict.Abstractions/Primitives/OpenIddictConfiguration.cs index 11cbc21c..aef5c490 100644 --- a/src/OpenIddict.Abstractions/Primitives/OpenIddictConfiguration.cs +++ b/src/OpenIddict.Abstractions/Primitives/OpenIddictConfiguration.cs @@ -86,6 +86,11 @@ public sealed class OpenIddictConfiguration /// public Uri? MtlsIntrospectionEndpoint { get; set; } + /// + /// Gets or sets the URI of the mTLS-enabled pushed authorization endpoint. + /// + public Uri? MtlsPushedAuthorizationEndpoint { get; set; } + /// /// Gets or sets the URI of the mTLS-enabled revocation endpoint. /// @@ -106,6 +111,21 @@ public sealed class OpenIddictConfiguration /// public Dictionary Properties { get; } = new(StringComparer.Ordinal); + /// + /// Gets or sets the URI of the pushed authorization endpoint. + /// + public Uri? PushedAuthorizationEndpoint { get; set; } + + /// + /// Gets the client authentication methods supported by the pushed authorization endpoint. + /// + public HashSet PushedAuthorizationEndpointAuthMethodsSupported { get; } = new(StringComparer.Ordinal); + + /// + /// Gets or sets a boolean indicating whether pushed authorization requests are required. + /// + public bool? RequirePushedAuthorizationRequests { get; set; } + /// /// Gets the response mode supported by the server. /// diff --git a/src/OpenIddict.Abstractions/Primitives/OpenIddictExtensions.cs b/src/OpenIddict.Abstractions/Primitives/OpenIddictExtensions.cs index e5215cd6..1f983d21 100644 --- a/src/OpenIddict.Abstractions/Primitives/OpenIddictExtensions.cs +++ b/src/OpenIddict.Abstractions/Primitives/OpenIddictExtensions.cs @@ -2558,6 +2558,22 @@ public static class OpenIddictExtensions public static TimeSpan? GetIdentityTokenLifetime(this ClaimsPrincipal principal) => GetLifetime(principal, Claims.Private.IdentityTokenLifetime); + /// + /// Gets the request token lifetime associated with the claims identity. + /// + /// The claims identity. + /// The request token lifetime or if the claim cannot be found. + public static TimeSpan? GetRequestTokenLifetime(this ClaimsIdentity identity) + => GetLifetime(identity, Claims.Private.RequestTokenLifetime); + + /// + /// Gets the request token lifetime associated with the claims principal. + /// + /// The claims principal. + /// The request token lifetime or if the claim cannot be found. + public static TimeSpan? GetRequestTokenLifetime(this ClaimsPrincipal principal) + => GetLifetime(principal, Claims.Private.RequestTokenLifetime); + /// /// Gets the refresh token lifetime associated with the claims identity. /// diff --git a/src/OpenIddict.Abstractions/Primitives/OpenIddictRequest.cs b/src/OpenIddict.Abstractions/Primitives/OpenIddictRequest.cs index c3a152ca..e6befbf8 100644 --- a/src/OpenIddict.Abstractions/Primitives/OpenIddictRequest.cs +++ b/src/OpenIddict.Abstractions/Primitives/OpenIddictRequest.cs @@ -375,6 +375,7 @@ public class OpenIddictRequest : OpenIddictMessage /// /// Gets or sets the "request_id" parameter. /// + [Obsolete("This property is obsolete and will be removed in a future version.")] public string? RequestId { get => (string?) GetParameter(OpenIddictConstants.Parameters.RequestId); diff --git a/src/OpenIddict.Abstractions/Primitives/OpenIddictResponse.cs b/src/OpenIddict.Abstractions/Primitives/OpenIddictResponse.cs index 0e78008d..70431cc2 100644 --- a/src/OpenIddict.Abstractions/Primitives/OpenIddictResponse.cs +++ b/src/OpenIddict.Abstractions/Primitives/OpenIddictResponse.cs @@ -198,6 +198,15 @@ public class OpenIddictResponse : OpenIddictMessage set => SetParameter(OpenIddictConstants.Parameters.RefreshToken, value); } + /// + /// Gets or sets the "request_uri" parameter. + /// + public string? RequestUri + { + get => (string?) GetParameter(OpenIddictConstants.Parameters.RequestUri); + set => SetParameter(OpenIddictConstants.Parameters.RequestUri, value); + } + /// /// Gets or sets the "scope" parameter. /// diff --git a/src/OpenIddict.Client.AspNetCore/OpenIddict.Client.AspNetCore.csproj b/src/OpenIddict.Client.AspNetCore/OpenIddict.Client.AspNetCore.csproj index fba06f9e..03f55a73 100644 --- a/src/OpenIddict.Client.AspNetCore/OpenIddict.Client.AspNetCore.csproj +++ b/src/OpenIddict.Client.AspNetCore/OpenIddict.Client.AspNetCore.csproj @@ -24,7 +24,6 @@ ('$(TargetFrameworkIdentifier)' == '.NETStandard') "> - diff --git a/src/OpenIddict.Client.Owin/OpenIddict.Client.Owin.csproj b/src/OpenIddict.Client.Owin/OpenIddict.Client.Owin.csproj index b05d3a81..b6937f17 100644 --- a/src/OpenIddict.Client.Owin/OpenIddict.Client.Owin.csproj +++ b/src/OpenIddict.Client.Owin/OpenIddict.Client.Owin.csproj @@ -14,7 +14,6 @@ - diff --git a/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Authorization.cs b/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Authorization.cs new file mode 100644 index 00000000..2b447bd1 --- /dev/null +++ b/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Authorization.cs @@ -0,0 +1,40 @@ +/* + * 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; + +namespace OpenIddict.Client.SystemNetHttp; + +public static partial class OpenIddictClientSystemNetHttpHandlers +{ + public static class Authorization + { + public static ImmutableArray DefaultHandlers { get; } = ImmutableArray.Create([ + /* + * Pushed authorization request processing: + */ + CreateHttpClient.Descriptor, + PreparePostHttpRequest.Descriptor, + AttachHttpVersion.Descriptor, + AttachJsonAcceptHeaders.Descriptor, + AttachUserAgentHeader.Descriptor, + AttachFromHeader.Descriptor, + AttachBasicAuthenticationCredentials.Descriptor, + AttachHttpParameters.Descriptor, + SendHttpRequest.Descriptor, + DisposeHttpRequest.Descriptor, + + /* + * Pushed authorization response processing: + */ + DecompressResponseContent.Descriptor, + ExtractJsonHttpResponse.Descriptor, + ExtractWwwAuthenticateHeader.Descriptor, + ValidateHttpResponse.Descriptor, + DisposeHttpResponse.Descriptor + ]); + } +} diff --git a/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Device.cs b/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Device.cs index f848d329..8b594e3e 100644 --- a/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Device.cs +++ b/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Device.cs @@ -14,7 +14,7 @@ public static partial class OpenIddictClientSystemNetHttpHandlers { public static ImmutableArray DefaultHandlers { get; } = ImmutableArray.Create([ /* - * DeviceAuthorization request processing: + * Device authorization request processing: */ CreateHttpClient.Descriptor, PreparePostHttpRequest.Descriptor, @@ -28,7 +28,7 @@ public static partial class OpenIddictClientSystemNetHttpHandlers DisposeHttpRequest.Descriptor, /* - * DeviceAuthorization response processing: + * Device authorization response processing: */ DecompressResponseContent.Descriptor, ExtractJsonHttpResponse.Descriptor, diff --git a/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.cs b/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.cs index 3625acaf..5bbeccc3 100644 --- a/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.cs +++ b/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.cs @@ -47,6 +47,7 @@ public static partial class OpenIddictClientSystemNetHttpHandlers */ AttachNonDefaultRevocationEndpointClientAuthenticationMethod.Descriptor, + .. Authorization.DefaultHandlers, .. Device.DefaultHandlers, .. Discovery.DefaultHandlers, .. Exchange.DefaultHandlers, diff --git a/src/OpenIddict.Client/OpenIddictClientEvents.Authentication.cs b/src/OpenIddict.Client/OpenIddictClientEvents.Authentication.cs index 5599f07d..976c4a26 100644 --- a/src/OpenIddict.Client/OpenIddictClientEvents.Authentication.cs +++ b/src/OpenIddict.Client/OpenIddictClientEvents.Authentication.cs @@ -78,6 +78,119 @@ public static partial class OpenIddictClientEvents public string AuthorizationEndpoint { get; set; } = null!; } + /// + /// Represents an event called for each request to the pushed authorization endpoint request + /// to give the user code a chance to add parameters to the pushed authorization request. + /// + public sealed class PreparePushedAuthorizationRequestContext : BaseExternalContext + { + /// + /// Creates a new instance of the class. + /// + public PreparePushedAuthorizationRequestContext(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 pushed authorization endpoint request + /// to send the pushed authorization request to the remote authorization server. + /// + public sealed class ApplyPushedAuthorizationRequestContext : BaseExternalContext + { + /// + /// Creates a new instance of the class. + /// + public ApplyPushedAuthorizationRequestContext(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 pushed authorization response + /// to extract the response parameters from the server response. + /// + public sealed class ExtractPushedAuthorizationResponseContext : BaseExternalContext + { + /// + /// Creates a new instance of the class. + /// + public ExtractPushedAuthorizationResponseContext(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 if it wasn't extracted yet. + /// + public OpenIddictResponse? Response + { + get => Transaction.Response; + set => Transaction.Response = value; + } + } + + /// + /// Represents an event called for each pushed authorization response. + /// + public sealed class HandlePushedAuthorizationResponseContext : BaseExternalContext + { + /// + /// Creates a new instance of the class. + /// + public HandlePushedAuthorizationResponseContext(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; + } + } + /// /// Represents an event called for each request to the redirection endpoint to give the user code /// a chance to manually extract the redirection request from the ambient HTTP context. diff --git a/src/OpenIddict.Client/OpenIddictClientEvents.cs b/src/OpenIddict.Client/OpenIddictClientEvents.cs index 84041ace..e7a93c66 100644 --- a/src/OpenIddict.Client/OpenIddictClientEvents.cs +++ b/src/OpenIddict.Client/OpenIddictClientEvents.cs @@ -1052,6 +1052,17 @@ public static partial class OpenIddictClientEvents /// public string? DeviceAuthorizationEndpointClientAuthenticationMethod { get; set; } + /// + /// Gets or sets the URI of the pushed authorization endpoint, if applicable. + /// + public Uri? PushedAuthorizationEndpoint { get; set; } + + /// + /// Gets or sets the client authentication method used when communicating + /// with the pushed authorization endpoint, if applicable. + /// + public string? PushedAuthorizationEndpointClientAuthenticationMethod { get; set; } + /// /// Gets or sets a boolean indicating whether a state token /// should be generated (and optionally included in the request). @@ -1082,6 +1093,11 @@ public static partial class OpenIddictClientEvents /// public bool SendDeviceAuthorizationRequest { get; set; } + /// + /// Gets or sets a boolean indicating whether a pushed authorization request should be sent. + /// + public bool SendPushedAuthorizationRequest { get; set; } + /// /// Gets or sets a boolean indicating whether a client assertion /// token should be generated (and optionally included in the request). @@ -1136,6 +1152,16 @@ public static partial class OpenIddictClientEvents /// public OpenIddictResponse? DeviceAuthorizationResponse { get; set; } + /// + /// Gets or sets the request sent to the pushed authorization endpoint, if applicable. + /// + public OpenIddictRequest? PushedAuthorizationRequest { get; set; } + + /// + /// Gets or sets the response returned by the pushed authorization endpoint, if applicable. + /// + public OpenIddictResponse? PushedAuthorizationResponse { get; set; } + /// /// Gets or sets a boolean indicating whether a device /// code should be extracted from the current context. @@ -1145,6 +1171,15 @@ public static partial class OpenIddictClientEvents /// public bool ExtractDeviceCode { get; set; } + /// + /// Gets or sets a boolean indicating whether a request token + /// should be extracted from the current context. + /// + /// + /// Note: overriding the value of this property is generally not recommended. + /// + public bool ExtractRequestToken { get; set; } + /// /// Gets or sets a boolean indicating whether a user /// code should be extracted from the current context. @@ -1163,6 +1198,15 @@ public static partial class OpenIddictClientEvents /// public bool RequireDeviceCode { get; set; } + /// + /// Gets or sets a boolean indicating whether a request token + /// must be resolved for the authentication to be considered valid. + /// + /// + /// Note: overriding the value of this property is generally not recommended. + /// + public bool RequireRequestToken { get; set; } + /// /// Gets or sets a boolean indicating whether a user code must /// be resolved for the authentication to be considered valid. @@ -1181,6 +1225,15 @@ public static partial class OpenIddictClientEvents /// public bool ValidateDeviceCode { get; set; } + /// + /// Gets or sets a boolean indicating whether the request token + /// extracted from the current context should be validated. + /// + /// + /// Note: overriding the value of this property is generally not recommended. + /// + public bool ValidateRequestToken { get; set; } + /// /// Gets or sets a boolean indicating whether the user code /// extracted from the current context should be validated. @@ -1199,6 +1252,15 @@ public static partial class OpenIddictClientEvents /// public bool RejectDeviceCode { get; set; } + /// + /// Gets or sets a boolean indicating whether an invalid request token + /// will cause the authentication demand to be rejected or will be ignored. + /// + /// + /// Note: overriding the value of this property is generally not recommended. + /// + public bool RejectRequestToken { get; set; } + /// /// Gets or sets a boolean indicating whether an invalid user code will /// cause the authentication demand to be rejected or will be ignored. @@ -1213,6 +1275,11 @@ public static partial class OpenIddictClientEvents /// public string? DeviceCode { get; set; } + /// + /// Gets or sets the request token to validate, if applicable. + /// + public string? RequestToken { get; set; } + /// /// Gets or sets the user code to validate, if applicable. /// diff --git a/src/OpenIddict.Client/OpenIddictClientExtensions.cs b/src/OpenIddict.Client/OpenIddictClientExtensions.cs index 4bd5a913..dd5f4146 100644 --- a/src/OpenIddict.Client/OpenIddictClientExtensions.cs +++ b/src/OpenIddict.Client/OpenIddictClientExtensions.cs @@ -56,6 +56,7 @@ public static class OpenIddictClientExtensions builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); diff --git a/src/OpenIddict.Client/OpenIddictClientHandlerFilters.cs b/src/OpenIddict.Client/OpenIddictClientHandlerFilters.cs index e01710e0..98d6cbc8 100644 --- a/src/OpenIddict.Client/OpenIddictClientHandlerFilters.cs +++ b/src/OpenIddict.Client/OpenIddictClientHandlerFilters.cs @@ -371,6 +371,23 @@ public static class OpenIddictClientHandlerFilters } } + /// + /// Represents a filter that excludes the associated handlers if no pushed authorization request is expected to be sent. + /// + public sealed class RequirePushedAuthorizationRequest : IOpenIddictClientHandlerFilter + { + /// + public ValueTask IsActiveAsync(ProcessChallengeContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + return new(context.SendPushedAuthorizationRequest); + } + } + /// /// Represents a filter that excludes the associated handlers if the request is not a redirection request. /// diff --git a/src/OpenIddict.Client/OpenIddictClientHandlers.Authentication.cs b/src/OpenIddict.Client/OpenIddictClientHandlers.Authentication.cs index c278de6c..1cc346d6 100644 --- a/src/OpenIddict.Client/OpenIddictClientHandlers.Authentication.cs +++ b/src/OpenIddict.Client/OpenIddictClientHandlers.Authentication.cs @@ -5,6 +5,7 @@ */ using System.Collections.Immutable; +using System.Text.Json; using Microsoft.Extensions.Logging; using OpenIddict.Extensions; @@ -31,6 +32,14 @@ public static partial class OpenIddictClientHandlers */ AttachAuthorizationEndpoint.Descriptor, + /* + * Pushed authorization response handling: + */ + ValidateWellKnownPushedAuthorizationResponseParameters.Descriptor, + HandlePushedAuthorizationErrorResponse.Descriptor, + ValidatePushedAuthorizationRequestUri.Descriptor, + ValidatePushedAuthorizationExpiration.Descriptor, + /* * Redirection request top-level processing: */ @@ -193,6 +202,210 @@ public static partial class OpenIddictClientHandlers } } + /// + /// Contains the logic responsible for validating the well-known parameters contained in the pushed authorization response. + /// + public sealed class ValidateWellKnownPushedAuthorizationResponseParameters : 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(HandlePushedAuthorizationResponseContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + foreach (var parameter in context.Response.GetParameters()) + { + if (!ValidateParameterType(parameter.Key, parameter.Value)) + { + context.Reject( + error: Errors.ServerError, + description: SR.FormatID2107(parameter.Key), + uri: SR.FormatID8000(SR.ID2107)); + + return default; + } + } + + return default; + + // Note: in the typical case, the response parameters should be deserialized from a + // JSON response and thus natively stored as System.Text.Json.JsonElement instances. + // + // In the rare cases where the underlying value wouldn't be a JsonElement instance + // (e.g when custom parameters are manually added to the response), the static + // conversion operator would take care of converting the underlying value to a + // JsonElement instance using the same value type as the original parameter value. + static bool ValidateParameterType(string name, OpenIddictParameter value) => name switch + { + // Error parameters MUST be formatted as unique strings: + Parameters.Error or Parameters.ErrorDescription or Parameters.ErrorUri + => ((JsonElement) value).ValueKind is JsonValueKind.String, + + // The following parameters MUST be formatted as unique strings: + Parameters.RequestUri => ((JsonElement) value).ValueKind is JsonValueKind.String, + + // The following parameters MUST be formatted as numeric dates: + Parameters.ExpiresIn => (JsonElement) value is { ValueKind: JsonValueKind.Number } element && + element.TryGetDecimal(out decimal result) && result is >= 0, + + // Parameters that are not in the well-known list can be of any type. + _ => true + }; + } + } + + /// + /// Contains the logic responsible for surfacing potential errors from the pushed authorization response. + /// + public sealed class HandlePushedAuthorizationErrorResponse : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ValidateWellKnownPushedAuthorizationResponseParameters.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(HandlePushedAuthorizationResponseContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // For more information, see https://www.rfc-editor.org/rfc/rfc8628#section-3.2. + if (!string.IsNullOrEmpty(context.Response.Error)) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6234), context.Response); + + context.Reject( + error: context.Response.Error switch + { + Errors.InvalidClient => Errors.InvalidRequest, + Errors.InvalidScope => Errors.InvalidScope, + Errors.InvalidRequest => Errors.InvalidRequest, + Errors.UnauthorizedClient => Errors.UnauthorizedClient, + _ => Errors.ServerError + }, + description: SR.GetResourceString(SR.ID2179), + uri: SR.FormatID8000(SR.ID2179)); + + return default; + } + + return default; + } + } + + /// + /// Contains the logic responsible for validating the request URI contained in the pushed authorization response. + /// + public sealed class ValidatePushedAuthorizationRequestUri : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(HandlePushedAuthorizationErrorResponse.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(HandlePushedAuthorizationResponseContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // Return an error if the mandatory "request_uri" parameter is missing. + // + // For more information, see https://datatracker.ietf.org/doc/html/rfc9126#section-2.2. + if (string.IsNullOrEmpty(context.Response.RequestUri)) + { + context.Reject( + error: Errors.ServerError, + description: SR.FormatID2180(Parameters.RequestUri), + uri: SR.FormatID8000(SR.ID2180)); + + return default; + } + + // Return an error if the "request_uri" parameter is malformed. + if (!Uri.TryCreate(context.Response.RequestUri, UriKind.Absolute, out Uri? uri) || + OpenIddictHelpers.IsImplicitFileUri(uri)) + { + context.Reject( + error: Errors.ServerError, + description: SR.FormatID2181(Parameters.RequestUri), + uri: SR.FormatID8000(SR.ID2181)); + + return default; + } + + return default; + } + } + + /// + /// Contains the logic responsible for validating the "expires_in" + /// parameter contained in the pushed authorization response. + /// + public sealed class ValidatePushedAuthorizationExpiration : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ValidatePushedAuthorizationRequestUri.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(HandlePushedAuthorizationResponseContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // Return an error if the mandatory "expires_in" parameter is missing. + // + // For more information, see https://datatracker.ietf.org/doc/html/rfc9126#section-2.2. + if (context.Response.ExpiresIn is null) + { + context.Reject( + error: Errors.ServerError, + description: SR.FormatID2180(Parameters.ExpiresIn), + uri: SR.FormatID8000(SR.ID2180)); + + return default; + } + + return default; + } + } + /// /// Contains the logic responsible for extracting redirection requests and invoking the corresponding event handlers. /// diff --git a/src/OpenIddict.Client/OpenIddictClientHandlers.Device.cs b/src/OpenIddict.Client/OpenIddictClientHandlers.Device.cs index e3d04fe0..bc241523 100644 --- a/src/OpenIddict.Client/OpenIddictClientHandlers.Device.cs +++ b/src/OpenIddict.Client/OpenIddictClientHandlers.Device.cs @@ -21,7 +21,8 @@ public static partial class OpenIddictClientHandlers */ ValidateWellKnownParameters.Descriptor, HandleErrorResponse.Descriptor, - ValidateVerificationEndpointUri.Descriptor + ValidateVerificationEndpointUri.Descriptor, + ValidateExpiration.Descriptor ]); /// diff --git a/src/OpenIddict.Client/OpenIddictClientHandlers.Discovery.cs b/src/OpenIddict.Client/OpenIddictClientHandlers.Discovery.cs index 2bb8f9c8..3bf29d14 100644 --- a/src/OpenIddict.Client/OpenIddictClientHandlers.Discovery.cs +++ b/src/OpenIddict.Client/OpenIddictClientHandlers.Discovery.cs @@ -31,9 +31,11 @@ public static partial class OpenIddictClientHandlers ExtractEndSessionEndpoint.Descriptor, ExtractMtlsDeviceAuthorizationEndpoint.Descriptor, ExtractMtlsIntrospectionEndpoint.Descriptor, + ExtractMtlsPushedAuthorizationEndpoint.Descriptor, ExtractMtlsRevocationEndpoint.Descriptor, ExtractMtlsTokenEndpoint.Descriptor, ExtractMtlsUserInfoEndpoint.Descriptor, + ExtractPushedAuthorizationEndpoint.Descriptor, ExtractRevocationEndpoint.Descriptor, ExtractTokenEndpoint.Descriptor, ExtractUserInfoEndpoint.Descriptor, @@ -44,8 +46,10 @@ public static partial class OpenIddictClientHandlers ExtractScopes.Descriptor, ExtractIssuerParameterRequirement.Descriptor, ExtractTlsClientCertificateAccessTokenBindingRequirement.Descriptor, + ExtractPushedAuthorizationRequirement.Descriptor, ExtractDeviceAuthorizationEndpointClientAuthenticationMethods.Descriptor, ExtractIntrospectionEndpointClientAuthenticationMethods.Descriptor, + ExtractPushedAuthorizationEndpointClientAuthenticationMethods.Descriptor, ExtractRevocationEndpointClientAuthenticationMethods.Descriptor, ExtractTokenEndpointClientAuthenticationMethods.Descriptor, @@ -109,29 +113,33 @@ public static partial class OpenIddictClientHandlers => ((JsonElement) value).ValueKind is JsonValueKind.String, // The following parameters MUST be formatted as unique strings: - Metadata.AuthorizationEndpoint or - Metadata.DeviceAuthorizationEndpoint or - Metadata.EndSessionEndpoint or - Metadata.Issuer or - Metadata.JwksUri or - Metadata.TokenEndpoint or + Metadata.AuthorizationEndpoint or + Metadata.DeviceAuthorizationEndpoint or + Metadata.EndSessionEndpoint or + Metadata.Issuer or + Metadata.JwksUri or + Metadata.PushedAuthorizationRequestEndpoint or + Metadata.TokenEndpoint or Metadata.UserInfoEndpoint => ((JsonElement) value).ValueKind is JsonValueKind.String, // The following parameters MUST be formatted as arrays of strings: - Metadata.CodeChallengeMethodsSupported or - Metadata.DeviceAuthorizationEndpointAuthMethodsSupported or - Metadata.GrantTypesSupported or - Metadata.ResponseModesSupported or - Metadata.ResponseTypesSupported or - Metadata.ScopesSupported or + Metadata.CodeChallengeMethodsSupported or + Metadata.DeviceAuthorizationEndpointAuthMethodsSupported or + Metadata.GrantTypesSupported or + Metadata.PushedAuthorizationRequestEndpointAuthMethodsSupported or + Metadata.ResponseModesSupported or + Metadata.ResponseTypesSupported or + Metadata.ScopesSupported or Metadata.TokenEndpointAuthMethodsSupported => ((JsonElement) value) is JsonElement element && element.ValueKind is JsonValueKind.Array && OpenIddictHelpers.ValidateArrayElements(element, JsonValueKind.String), // The following parameters MUST be formatted as booleans: - Metadata.AuthorizationResponseIssParameterSupported + Metadata.AuthorizationResponseIssParameterSupported or + Metadata.RequirePushedAuthorizationRequests or + Metadata.TlsClientCertificateBoundAccessTokens => ((JsonElement) value).ValueKind is JsonValueKind.True or JsonValueKind.False, // Parameters that are not in the well-known list can be of any type. @@ -564,6 +572,47 @@ public static partial class OpenIddictClientHandlers } } + /// + /// Contains the logic responsible for extracting the mTLS-enabled pushed authorization endpoint URI from the discovery document. + /// + public sealed class ExtractMtlsPushedAuthorizationEndpoint : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ExtractMtlsIntrospectionEndpoint.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(HandleConfigurationResponseContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + var aliases = context.Response[Metadata.MtlsEndpointAliases]?.GetNamedParameters(); + if (aliases is not { Count: > 0 }) + { + return default; + } + + // Note: as recommended by the specification, values present in the "mtls_endpoint_aliases" node + // that can't be recognized as OAuth 2.0 endpoints or are not valid URIs are simply ignored. + var endpoint = (string?) aliases[Metadata.PushedAuthorizationRequestEndpoint]; + if (Uri.TryCreate(endpoint, UriKind.Absolute, out Uri? uri) && !OpenIddictHelpers.IsImplicitFileUri(uri)) + { + context.Configuration.MtlsPushedAuthorizationEndpoint = uri; + } + + return default; + } + } + /// /// Contains the logic responsible for extracting the mTLS-enabled revocation endpoint URI from the discovery document. /// @@ -575,7 +624,7 @@ public static partial class OpenIddictClientHandlers public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() .UseSingletonHandler() - .SetOrder(ExtractMtlsIntrospectionEndpoint.Descriptor.Order + 1_000) + .SetOrder(ExtractMtlsPushedAuthorizationEndpoint.Descriptor.Order + 1_000) .SetType(OpenIddictClientHandlerType.BuiltIn) .Build(); @@ -687,6 +736,49 @@ public static partial class OpenIddictClientHandlers } } + /// + /// Contains the logic responsible for extracting the pushed authorization endpoint URI from the discovery document. + /// + public sealed class ExtractPushedAuthorizationEndpoint : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ExtractEndSessionEndpoint.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(HandleConfigurationResponseContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + var endpoint = (string?) context.Response[Metadata.PushedAuthorizationRequestEndpoint]; + if (!string.IsNullOrEmpty(endpoint)) + { + if (!Uri.TryCreate(endpoint, UriKind.Absolute, out Uri? uri) || OpenIddictHelpers.IsImplicitFileUri(uri)) + { + context.Reject( + error: Errors.ServerError, + description: SR.FormatID2100(Metadata.PushedAuthorizationRequestEndpoint), + uri: SR.FormatID8000(SR.ID2100)); + + return default; + } + + context.Configuration.PushedAuthorizationEndpoint = uri; + } + + return default; + } + } + /// /// Contains the logic responsible for extracting the revocation endpoint URI from the discovery document. /// @@ -698,7 +790,7 @@ public static partial class OpenIddictClientHandlers public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() .UseSingletonHandler() - .SetOrder(ExtractEndSessionEndpoint.Descriptor.Order + 1_000) + .SetOrder(ExtractPushedAuthorizationEndpoint.Descriptor.Order + 1_000) .SetType(OpenIddictClientHandlerType.BuiltIn) .Build(); @@ -1088,6 +1180,37 @@ public static partial class OpenIddictClientHandlers } } + /// + /// Contains the logic responsible for extracting the flag indicating whether pushed + /// authorization requests (PAR) are considered mandatory from the discovery document. + /// + public sealed class ExtractPushedAuthorizationRequirement : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ExtractTlsClientCertificateAccessTokenBindingRequirement.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(HandleConfigurationResponseContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + context.Configuration.RequirePushedAuthorizationRequests = (bool?) + context.Response[Metadata.RequirePushedAuthorizationRequests]; + + return default; + } + } + /// /// Contains the logic responsible for extracting the authentication methods /// supported by the device authorization endpoint from the discovery document. @@ -1100,7 +1223,7 @@ public static partial class OpenIddictClientHandlers public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() .UseSingletonHandler() - .SetOrder(ExtractTlsClientCertificateAccessTokenBindingRequirement.Descriptor.Order + 1_000) + .SetOrder(ExtractPushedAuthorizationRequirement.Descriptor.Order + 1_000) .SetType(OpenIddictClientHandlerType.BuiltIn) .Build(); @@ -1177,6 +1300,52 @@ public static partial class OpenIddictClientHandlers } } + /// + /// Contains the logic responsible for extracting the authentication methods + /// supported by the pushed authorization endpoint from the discovery document. + /// + public sealed class ExtractPushedAuthorizationEndpointClientAuthenticationMethods : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ExtractTlsClientCertificateAccessTokenBindingRequirement.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 pushed authorization endpoint, if available. + // + // Note: "pushed_authorization_request_endpoint_auth_methods_supported" is not a standard parameter + // but is supported by OpenIddict 6.1.0 and higher for consistency with the other endpoints. + var methods = context.Response[Metadata.PushedAuthorizationRequestEndpointAuthMethodsSupported]?.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.PushedAuthorizationEndpointAuthMethodsSupported.Add(method); + } + } + } + + return default; + } + } + /// /// Contains the logic responsible for extracting the authentication methods /// supported by the revocation endpoint from the discovery document. @@ -1189,7 +1358,7 @@ public static partial class OpenIddictClientHandlers public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() .UseSingletonHandler() - .SetOrder(ExtractIntrospectionEndpointClientAuthenticationMethods.Descriptor.Order + 1_000) + .SetOrder(ExtractPushedAuthorizationEndpointClientAuthenticationMethods.Descriptor.Order + 1_000) .SetType(OpenIddictClientHandlerType.BuiltIn) .Build(); diff --git a/src/OpenIddict.Client/OpenIddictClientHandlers.cs b/src/OpenIddict.Client/OpenIddictClientHandlers.cs index 5de69eb7..28318145 100644 --- a/src/OpenIddict.Client/OpenIddictClientHandlers.cs +++ b/src/OpenIddict.Client/OpenIddictClientHandlers.cs @@ -120,13 +120,21 @@ public static partial class OpenIddictClientHandlers GenerateLoginStateToken.Descriptor, AttachChallengeParameters.Descriptor, AttachCustomChallengeParameters.Descriptor, + EvaluateDeviceAuthorizationRequest.Descriptor, AttachDeviceAuthorizationEndpointClientAuthenticationMethod.Descriptor, ResolveDeviceAuthorizationEndpoint.Descriptor, AttachDeviceAuthorizationRequestParameters.Descriptor, + + EvaluatePushedAuthorizationRequest.Descriptor, + AttachPushedAuthorizationEndpointClientAuthenticationMethod.Descriptor, + ResolvePushedAuthorizationEndpoint.Descriptor, + AttachPushedAuthorizationRequestParameters.Descriptor, + EvaluateGeneratedChallengeClientAssertion.Descriptor, PrepareChallengeClientAssertionPrincipal.Descriptor, GenerateChallengeClientAssertion.Descriptor, + AttachDeviceAuthorizationRequestClientCredentials.Descriptor, SendDeviceAuthorizationRequest.Descriptor, @@ -134,6 +142,17 @@ public static partial class OpenIddictClientHandlers ResolveValidatedDeviceAuthorizationTokens.Descriptor, ValidateRequiredDeviceAuthorizationTokens.Descriptor, + AttachPushedAuthorizationRequestClientCredentials.Descriptor, + ValidatePushedAuthorizationRequirement.Descriptor, + SendPushedAuthorizationRequest.Descriptor, + + EvaluateValidatedPushedTokens.Descriptor, + ResolveValidatedPushedTokens.Descriptor, + ValidateRequiredPushedAuthorizationTokens.Descriptor, + + AttachRequestToken.Descriptor, + RemovePushedAuthorizationRequestParameters.Descriptor, + /* * Introspection processing: */ @@ -880,7 +899,7 @@ public static partial class OpenIddictClientHandlers { context.Reject( error: Errors.InvalidRequest, - description: SR.GetResourceString(SR.ID2142), + description: SR.FormatID2142(Parameters.State), uri: SR.FormatID8000(SR.ID2142)); return default; @@ -5737,6 +5756,205 @@ public static partial class OpenIddictClientHandlers } } + /// + /// Contains the logic responsible for determining whether a pushed authorization request should be sent. + /// + public sealed class EvaluatePushedAuthorizationRequest : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(AttachDeviceAuthorizationRequestParameters.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessChallengeContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + context.SendPushedAuthorizationRequest = context.GrantType switch + { + // For interactive grant types, always send a pushed authorization request by default if + // the authorization endpoint exposes a pushed authorization request endpoint and pushed + // authorization requests were was not explicitly opted out in the client registration. + GrantTypes.AuthorizationCode or GrantTypes.Implicit + when context.Configuration.PushedAuthorizationEndpoint is not null && + !context.Registration.DisablePushedAuthorizationRequests => true, + + // Apply the same logic to the special response_type=none flow. + null when context.ResponseType is ResponseTypes.None && + context.Configuration.PushedAuthorizationEndpoint is not null && + !context.Registration.DisablePushedAuthorizationRequests => true, + + // Otherwise, do not send a pushed authorization request. + _ => false + }; + + return default; + } + } + + /// + /// Contains the logic responsible for negotiating the best pushed authorization endpoint + /// client authentication method supported by both the client and the authorization server. + /// + public sealed class AttachPushedAuthorizationEndpointClientAuthenticationMethod : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(EvaluatePushedAuthorizationRequest.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessChallengeContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // If an explicit client authentication method was attached, don't overwrite it. + if (!string.IsNullOrEmpty(context.PushedAuthorizationEndpointClientAuthenticationMethod)) + { + return default; + } + + context.PushedAuthorizationEndpointClientAuthenticationMethod = ( + // Note: if client authentication methods are explicitly listed in the client registration, only use + // the client authentication methods that are both listed and enabled in the global client options. + // Otherwise, always default to the client authentication methods that have been enabled globally. + Client: context.Registration.ClientAuthenticationMethods.Count switch + { + 0 => context.Options.ClientAuthenticationMethods as ICollection, + _ => context.Options.ClientAuthenticationMethods.Intersect(context.Registration.ClientAuthenticationMethods, StringComparer.Ordinal).ToList() + }, + + // Note: if the authorization server doesn't support the OpenIddict-specific + // "pushed_authorization_request_endpoint_auth_methods_supported" node, fall back to + // the "token_endpoint_auth_methods_supported" node, as required by the specification. + // + // See https://datatracker.ietf.org/doc/html/rfc9126#section-2 for more information. + Server: context.Configuration.PushedAuthorizationEndpointAuthMethodsSupported.Count switch + { + 0 => context.Configuration.TokenEndpointAuthMethodsSupported, + _ => context.Configuration.PushedAuthorizationEndpointAuthMethodsSupported, + }) switch + { + // If at least one signing key was attached to the client registration and both + // the client and the server explicitly support private_key_jwt, always prefer it. + ({ Count: > 0 } client, { Count: > 0 } server) when context.Registration.SigningCredentials.Count is not 0 && + client.Contains(ClientAuthenticationMethods.PrivateKeyJwt) && + server.Contains(ClientAuthenticationMethods.PrivateKeyJwt) + => ClientAuthenticationMethods.PrivateKeyJwt, + + // If a client secret was attached to the client registration and both the client and + // the server explicitly support client_secret_post, prefer it to basic authentication. + ({ Count: > 0 } client, { Count: > 0 } server) when !string.IsNullOrEmpty(context.Registration.ClientSecret) && + client.Contains(ClientAuthenticationMethods.ClientSecretPost) && + server.Contains(ClientAuthenticationMethods.ClientSecretPost) + => ClientAuthenticationMethods.ClientSecretPost, + + _ => null + }; + + return default; + } + } + + /// + /// Contains the logic responsible for resolving the URI of the pushed authorization endpoint. + /// + public sealed class ResolvePushedAuthorizationEndpoint : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(AttachPushedAuthorizationEndpointClientAuthenticationMethod.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessChallengeContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // If the URI of the pushed authorization endpoint endpoint wasn't + // explicitly set at this stage, try to extract it from the server configuration. + context.PushedAuthorizationEndpoint ??= context.PushedAuthorizationEndpointClientAuthenticationMethod switch + { + // When TLS client certificate authentication was negotiated, + // always favor the mTLS-specific endpoint if available. + ClientAuthenticationMethods.SelfSignedTlsClientAuth or ClientAuthenticationMethods.TlsClientAuth + when context.Configuration.MtlsPushedAuthorizationEndpoint is { IsAbsoluteUri: true } uri && + !OpenIddictHelpers.IsImplicitFileUri(uri) => uri, + + // Otherwise, use the non-mTLS-specific endpoint. + _ when context.Configuration.PushedAuthorizationEndpoint is { IsAbsoluteUri: true } uri && + !OpenIddictHelpers.IsImplicitFileUri(uri) => uri, + + _ => null + }; + + return default; + } + } + + /// + /// Contains the logic responsible for attaching the parameters to the pushed authorization request, if applicable. + /// + public sealed class AttachPushedAuthorizationRequestParameters : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(ResolvePushedAuthorizationEndpoint.Descriptor.Order + 1_000) + .Build(); + + /// + public ValueTask HandleAsync(ProcessChallengeContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // Attach a new request instance if necessary. + context.PushedAuthorizationRequest ??= new OpenIddictRequest(); + + // Copy all the challenge parameters to the pushed authorization request instance. + foreach (var parameter in context.Request.GetParameters()) + { + context.PushedAuthorizationRequest.AddParameter(parameter.Key, parameter.Value); + } + + return default; + } + } + /// /// Contains the logic responsible for selecting the token types that should /// be generated and optionally sent as part of the challenge demand. @@ -5762,12 +5980,12 @@ public static partial class OpenIddictClientHandlers throw new ArgumentNullException(nameof(context)); } - (context.GenerateClientAssertion, - context.IncludeClientAssertion) = context.DeviceAuthorizationEndpointClientAuthenticationMethod switch + (context.GenerateClientAssertion, context.IncludeClientAssertion) = context switch { // If the private_key_jwt client authentication method could be negotiated, // generate a client assertion that will be used to authenticate the client. - ClientAuthenticationMethods.PrivateKeyJwt => (true, true), + { DeviceAuthorizationEndpointClientAuthenticationMethod: ClientAuthenticationMethods.PrivateKeyJwt } => (true, true), + { PushedAuthorizationEndpointClientAuthenticationMethod: ClientAuthenticationMethods.PrivateKeyJwt } => (true, true), _ => (false, false) }; @@ -6148,6 +6366,329 @@ public static partial class OpenIddictClientHandlers } } + /// + /// Contains the logic responsible for attaching the client credentials to the pushed authorization endpoint request, if applicable. + /// + public sealed class AttachPushedAuthorizationRequestClientCredentials : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(ValidateRequiredDeviceAuthorizationTokens.Descriptor.Order + 1_000) + .Build(); + + /// + public ValueTask HandleAsync(ProcessChallengeContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(context.PushedAuthorizationRequest is not null, SR.GetResourceString(SR.ID4008)); + + // Always attach the client_id to the request, even if an assertion is sent or mTLS is used. + context.PushedAuthorizationRequest.ClientId = context.ClientId; + + // Note: client authentication methods are mutually exclusive so the client_assertion + // and client_secret parameters MUST never be sent at the same time. For more information, + // see https://datatracker.ietf.org/doc/html/rfc6749#section-2.3. + if (context.IncludeClientAssertion) + { + context.PushedAuthorizationRequest.ClientAssertion = context.ClientAssertion; + context.PushedAuthorizationRequest.ClientAssertionType = context.ClientAssertionType; + } + + else if (context.PushedAuthorizationEndpointClientAuthenticationMethod is + ClientAuthenticationMethods.ClientSecretBasic or + ClientAuthenticationMethods.ClientSecretPost) + { + context.PushedAuthorizationRequest.ClientSecret = context.Registration.ClientSecret; + } + + return default; + } + } + + /// + /// Contains the logic responsible for aborting authentication demands pointing to client registrations that + /// disallow using pushed authorization requests if the authorization server requires using this feature. + /// + public sealed class ValidatePushedAuthorizationRequirement : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(AttachPushedAuthorizationRequestClientCredentials.Descriptor.Order + 1_000) + .Build(); + + /// + public ValueTask HandleAsync(ProcessChallengeContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (!context.SendPushedAuthorizationRequest && context.Configuration.RequirePushedAuthorizationRequests is true) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0460)); + } + + return default; + } + } + + /// + /// Contains the logic responsible for sending the pushed authorization endpoint request, if applicable. + /// + public sealed class SendPushedAuthorizationRequest : IOpenIddictClientHandler + { + private readonly OpenIddictClientService _service; + + public SendPushedAuthorizationRequest(OpenIddictClientService service) + => _service = service ?? throw new ArgumentNullException(nameof(service)); + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(ValidatePushedAuthorizationRequirement.Descriptor.Order + 1_000) + .Build(); + + /// + public async ValueTask HandleAsync(ProcessChallengeContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(context.PushedAuthorizationRequest is not null, SR.GetResourceString(SR.ID4008)); + + // Ensure the pushed authorization endpoint is present and is a valid absolute URI. + if (context.PushedAuthorizationEndpoint is not { IsAbsoluteUri: true } || + OpenIddictHelpers.IsImplicitFileUri(context.PushedAuthorizationEndpoint)) + { + throw new InvalidOperationException(SR.FormatID0301(Metadata.PushedAuthorizationRequestEndpoint)); + } + + try + { + context.PushedAuthorizationResponse = await _service.SendPushedAuthorizationRequestAsync( + context.Registration, context.Configuration, + context.PushedAuthorizationRequest, context.PushedAuthorizationEndpoint, + context.PushedAuthorizationEndpointClientAuthenticationMethod, context.CancellationToken); + } + + catch (ProtocolException exception) + { + context.Reject( + error: exception.Error, + description: exception.ErrorDescription, + uri: exception.ErrorUri); + + return; + } + } + } + + /// + /// Contains the logic responsible for determining the set of pushed authorization tokens to validate. + /// + public sealed class EvaluateValidatedPushedTokens : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(SendPushedAuthorizationRequest.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessChallengeContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + (context.ExtractRequestToken, + context.RequireRequestToken, + context.ValidateRequestToken, + context.RejectRequestToken) = context.SendPushedAuthorizationRequest switch + { + // A request_uri parameter (whose content is called a request token in + // OpenIddict) is always returned as part of pushed authorization responses. + // + // Note: since request 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 + // request tokens that use a readable format (e.g JWT). + true => (true, true, false, false), + + _ => (false, false, false, false) + }; + + return default; + } + } + + /// + /// Contains the logic responsible for resolving the pushed + /// tokens from the pushed authorization response, if applicable. + /// + public sealed class ResolveValidatedPushedTokens : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(EvaluateValidatedPushedTokens.Descriptor.Order + 1_000) + .Build(); + + /// + public ValueTask HandleAsync(ProcessChallengeContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(context.PushedAuthorizationResponse is not null, SR.GetResourceString(SR.ID4007)); + + context.RequestToken = context.ExtractRequestToken ? context.PushedAuthorizationResponse.RequestUri : null; + + return default; + } + } + + /// + /// Contains the logic responsible for rejecting challenge demands that lack required tokens. + /// + public sealed class ValidateRequiredPushedAuthorizationTokens : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + // Note: this handler is registered with a high gap to allow handlers + // that do token extraction to be executed before this handler runs. + .SetOrder(ResolveValidatedPushedTokens.Descriptor.Order + 50_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessChallengeContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (context.RequireRequestToken && string.IsNullOrEmpty(context.RequestToken)) + { + context.Reject( + error: Errors.MissingToken, + description: SR.GetResourceString(SR.ID2000), + uri: SR.FormatID8000(SR.ID2000)); + + return default; + } + + return default; + } + } + + /// + /// Contains the logic responsible for attaching the request token to the authorization request. + /// + public sealed class AttachRequestToken : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(ValidateRequiredPushedAuthorizationTokens.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessChallengeContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + context.Request.RequestUri = context.RequestToken; + + return default; + } + } + + /// + /// Contains the logic responsible for removing parameters that were sent as + /// part of the pushed authorization request from the authorization request. + /// + public sealed class RemovePushedAuthorizationRequestParameters : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .AddFilter() + .UseSingletonHandler() + .SetOrder(AttachRequestToken.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessChallengeContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(context.PushedAuthorizationRequest is not null, SR.GetResourceString(SR.ID4008)); + + // Filter out all the parameters that were sent in the pushed authorization request from + // the regular authorization request, except the "client_id" parameter, as required + // by the specification: https://datatracker.ietf.org/doc/html/rfc9101#section-5. + context.Request = new OpenIddictRequest( + from parameter in context.Request.GetParameters() + where parameter.Key is Parameters.ClientId || !context.PushedAuthorizationRequest.HasParameter(parameter.Key) + select parameter); + + return default; + } + } + /// /// Contains the logic responsible for rejecting invalid introspection demands. /// diff --git a/src/OpenIddict.Client/OpenIddictClientRegistration.cs b/src/OpenIddict.Client/OpenIddictClientRegistration.cs index 5e973812..48e6c8d4 100644 --- a/src/OpenIddict.Client/OpenIddictClientRegistration.cs +++ b/src/OpenIddict.Client/OpenIddictClientRegistration.cs @@ -32,6 +32,15 @@ public sealed class OpenIddictClientRegistration /// public string? ClientSecret { get; set; } + /// + /// Gets or sets a boolean indicating whether pushed authorization requests are disabled. + /// When pushed authorization requests are disabled, PAR is not used by the OpenIddict client, + /// even if the remote authorization server exposes a pushed authorization endpoint. If the + /// authorization server requires using PAR but this property is set to , + /// an exception is automatically thrown when starting an interactive authentication challenge. + /// + public bool DisablePushedAuthorizationRequests { get; set; } + /// /// Gets or sets the URI of the redirection endpoint that will handle the callback. /// diff --git a/src/OpenIddict.Client/OpenIddictClientService.cs b/src/OpenIddict.Client/OpenIddictClientService.cs index cad51c4e..7a7ca810 100644 --- a/src/OpenIddict.Client/OpenIddictClientService.cs +++ b/src/OpenIddict.Client/OpenIddictClientService.cs @@ -806,7 +806,8 @@ public class OpenIddictClientService Issuer = request.Issuer, Principal = new ClaimsPrincipal(new ClaimsIdentity()), ProviderName = request.ProviderName, - RegistrationId = request.RegistrationId + RegistrationId = request.RegistrationId, + Request = new() }; if (request.Scopes is { Count: > 0 }) @@ -1962,6 +1963,182 @@ public class OpenIddictClientService } } + /// + /// Sends the pushed authorization request and retrieves the corresponding response. + /// + /// The client registration. + /// The server configuration. + /// The pushed authorization request. + /// The uri of the remote pushed authorization endpoint. + /// The client authentication method, if applicable. + /// The that can be used to abort the operation. + /// The token response. + internal async ValueTask SendPushedAuthorizationRequestAsync( + OpenIddictClientRegistration registration, OpenIddictConfiguration configuration, + OpenIddictRequest request, Uri uri, string? method, CancellationToken cancellationToken = default) + { + if (registration is null) + { + throw new ArgumentNullException(nameof(registration)); + } + + if (configuration is null) + { + throw new ArgumentNullException(nameof(configuration)); + } + + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + if (uri is null) + { + throw new ArgumentNullException(nameof(uri)); + } + + if (!uri.IsAbsoluteUri || OpenIddictHelpers.IsImplicitFileUri(uri)) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0144), nameof(uri)); + } + + cancellationToken.ThrowIfCancellationRequested(); + + // Note: this service is registered as a singleton service. As such, it cannot + // directly depend on scoped services like the validation provider. To work around + // this limitation, a scope is manually created for each method to this service. + var scope = _provider.CreateScope(); + + // Note: a try/finally block is deliberately used here to ensure the service scope + // can be disposed of asynchronously if it implements IAsyncDisposable. + try + { + var dispatcher = scope.ServiceProvider.GetRequiredService(); + var factory = scope.ServiceProvider.GetRequiredService(); + var transaction = await factory.CreateTransactionAsync(); + + request = await PreparePushedAuthorizationRequestAsync(); + request = await ApplyPushedAuthorizationRequestAsync(); + + var response = await ExtractPushedAuthorizationResponseAsync(); + + return await HandlePushedAuthorizationResponseAsync(); + + async ValueTask PreparePushedAuthorizationRequestAsync() + { + var context = new PreparePushedAuthorizationRequestContext(transaction) + { + CancellationToken = cancellationToken, + ClientAuthenticationMethod = method, + RemoteUri = uri, + Configuration = configuration, + Registration = registration, + Request = request + }; + + await dispatcher.DispatchAsync(context); + + if (context.IsRejected) + { + throw new ProtocolException( + SR.FormatID0461(context.Error, context.ErrorDescription, context.ErrorUri), + context.Error, context.ErrorDescription, context.ErrorUri); + } + + return context.Request; + } + + async ValueTask ApplyPushedAuthorizationRequestAsync() + { + var context = new ApplyPushedAuthorizationRequestContext(transaction) + { + CancellationToken = cancellationToken, + RemoteUri = uri, + Configuration = configuration, + Registration = registration, + Request = request + }; + + await dispatcher.DispatchAsync(context); + + if (context.IsRejected) + { + throw new ProtocolException( + SR.FormatID0462(context.Error, context.ErrorDescription, context.ErrorUri), + context.Error, context.ErrorDescription, context.ErrorUri); + } + + context.Logger.LogInformation(SR.GetResourceString(SR.ID6235), context.RemoteUri, context.Request); + + return context.Request; + } + + async ValueTask ExtractPushedAuthorizationResponseAsync() + { + var context = new ExtractPushedAuthorizationResponseContext(transaction) + { + CancellationToken = cancellationToken, + RemoteUri = uri, + Configuration = configuration, + Registration = registration, + Request = request + }; + + await dispatcher.DispatchAsync(context); + + if (context.IsRejected) + { + throw new ProtocolException( + SR.FormatID0463(context.Error, context.ErrorDescription, context.ErrorUri), + context.Error, context.ErrorDescription, context.ErrorUri); + } + + Debug.Assert(context.Response is not null, SR.GetResourceString(SR.ID4007)); + + context.Logger.LogInformation(SR.GetResourceString(SR.ID6236), context.RemoteUri, context.Response); + + return context.Response; + } + + async ValueTask HandlePushedAuthorizationResponseAsync() + { + var context = new HandlePushedAuthorizationResponseContext(transaction) + { + CancellationToken = cancellationToken, + RemoteUri = uri, + Configuration = configuration, + Registration = registration, + Request = request, + Response = response + }; + + await dispatcher.DispatchAsync(context); + + if (context.IsRejected) + { + throw new ProtocolException( + SR.FormatID0464(context.Error, context.ErrorDescription, context.ErrorUri), + context.Error, context.ErrorDescription, context.ErrorUri); + } + + return context.Response; + } + } + + finally + { + if (scope is IAsyncDisposable disposable) + { + await disposable.DisposeAsync(); + } + + else + { + scope.Dispose(); + } + } + } + /// /// Sends the revocation request and retrieves the corresponding response. /// diff --git a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreBuilder.cs b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreBuilder.cs index 9d408d37..3c485a49 100644 --- a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreBuilder.cs +++ b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreBuilder.cs @@ -7,6 +7,7 @@ using System.ComponentModel; using Microsoft.AspNetCore; using Microsoft.Extensions.Caching.Distributed; +using OpenIddict.Server; using OpenIddict.Server.AspNetCore; namespace Microsoft.Extensions.DependencyInjection; @@ -127,16 +128,26 @@ public sealed class OpenIddictServerAspNetCoreBuilder /// OpenID Connect authorization requests support is required. /// /// The instance. + [Obsolete("This method is obsolete and will be removed in a future version.")] public OpenIddictServerAspNetCoreBuilder EnableAuthorizationRequestCaching() - => Configure(options => options.EnableAuthorizationRequestCaching = true); + { + Services.Configure(options => options.EnableAuthorizationRequestCaching = true); + + return this; + } /// /// Enables end session request caching, so that end session requests /// are automatically stored in the distributed cache. /// /// The instance. + [Obsolete("This method is obsolete and will be removed in a future version.")] public OpenIddictServerAspNetCoreBuilder EnableEndSessionRequestCaching() - => Configure(options => options.EnableEndSessionRequestCaching = true); + { + Services.Configure(options => options.EnableEndSessionRequestCaching = true); + + return this; + } /// /// Enables status code pages integration support. Once enabled, errors @@ -174,6 +185,7 @@ public sealed class OpenIddictServerAspNetCoreBuilder /// /// The caching policy. /// The instance. + [Obsolete("This method is obsolete and will be removed in a future version.")] public OpenIddictServerAspNetCoreBuilder SetAuthorizationRequestCachingPolicy(DistributedCacheEntryOptions policy) { if (policy is null) @@ -190,6 +202,7 @@ public sealed class OpenIddictServerAspNetCoreBuilder /// /// The caching policy. /// The instance. + [Obsolete("This method is obsolete and will be removed in a future version.")] public OpenIddictServerAspNetCoreBuilder SetEndSessionRequestCachingPolicy(DistributedCacheEntryOptions policy) { if (policy is null) diff --git a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreConstants.cs b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreConstants.cs index d8a8ced7..2d91a5ee 100644 --- a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreConstants.cs +++ b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreConstants.cs @@ -11,12 +11,14 @@ namespace OpenIddict.Server.AspNetCore; /// public static class OpenIddictServerAspNetCoreConstants { + [Obsolete("This class is obsolete and will be removed in a future version.")] public static class Cache { public const string AuthorizationRequest = "openiddict-authorization-request:"; public const string EndSessionRequest = "openiddict-end_session-request:"; } + [Obsolete("This class is obsolete and will be removed in a future version.")] public static class JsonWebTokenTypes { public static class Private @@ -37,6 +39,7 @@ public static class OpenIddictServerAspNetCoreConstants public const string ErrorUri = ".error_uri"; public const string IdentityTokenPrincipal = ".identity_token_principal"; public const string RefreshTokenPrincipal = ".refresh_token_principal"; + public const string RequestTokenPrincipal = ".request_token_principal"; public const string Scope = ".scope"; public const string UserCodePrincipal = ".user_code_principal"; } @@ -48,6 +51,7 @@ public static class OpenIddictServerAspNetCoreConstants public const string ClientAssertion = "client_assertion"; public const string DeviceCode = "device_code"; public const string IdentityToken = "id_token"; + public const string RequestToken = "request_token"; public const string RefreshToken = "refresh_token"; public const string UserCode = "user_code"; } diff --git a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreExtensions.cs b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreExtensions.cs index 490f4cf0..a383b233 100644 --- a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreExtensions.cs +++ b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreExtensions.cs @@ -38,9 +38,11 @@ public static class OpenIddictServerAspNetCoreExtensions builder.Services.TryAdd(OpenIddictServerAspNetCoreHandlers.DefaultHandlers.Select(descriptor => descriptor.ServiceDescriptor)); // Register the built-in filters used by the default OpenIddict ASP.NET Core server event handlers. +#pragma warning disable CS0618 builder.Services.TryAddSingleton(); - builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); +#pragma warning restore CS0618 + builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); diff --git a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandler.cs b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandler.cs index 2dc257d6..a60b08e7 100644 --- a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandler.cs +++ b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandler.cs @@ -178,12 +178,7 @@ public sealed class OpenIddictServerAspNetCoreHandler : AuthenticationHandler context.UserCodePrincipal, OpenIddictServerEndpointType.Introspection or OpenIddictServerEndpointType.Revocation - => context.AccessTokenPrincipal ?? - context.RefreshTokenPrincipal ?? - context.IdentityTokenPrincipal ?? - context.AuthorizationCodePrincipal ?? - context.DeviceCodePrincipal ?? - context.UserCodePrincipal, + => context.GenericTokenPrincipal, OpenIddictServerEndpointType.Token when context.Request.IsAuthorizationCodeGrantType() => context.AuthorizationCodePrincipal, @@ -282,6 +277,16 @@ public sealed class OpenIddictServerAspNetCoreHandler : AuthenticationHandler /// Represents a filter that excludes the associated handlers if authorization request caching was not enabled. /// + [Obsolete("This filter is obsolete and will be removed in a future version.")] public sealed class RequireAuthorizationRequestCachingEnabled : IOpenIddictServerHandlerFilter { private readonly IOptionsMonitor _options; @@ -64,6 +65,7 @@ public static class OpenIddictServerAspNetCoreHandlerFilters /// /// Represents a filter that excludes the associated handlers if end session request caching was not enabled. /// + [Obsolete("This filter is obsolete and will be removed in a future version.")] public sealed class RequireEndSessionRequestCachingEnabled : IOpenIddictServerHandlerFilter { private readonly IOptionsMonitor _options; diff --git a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Authentication.cs b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Authentication.cs index f203bfd3..570d1980 100644 --- a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Authentication.cs +++ b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Authentication.cs @@ -5,22 +5,14 @@ */ using System.Collections.Immutable; -using System.Diagnostics; -using System.Security.Claims; using System.Text; using System.Text.Encodings.Web; -using System.Text.Json; using Microsoft.AspNetCore; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.JsonWebTokens; -using Microsoft.IdentityModel.Tokens; using Microsoft.Net.Http.Headers; -using OpenIddict.Extensions; -using static OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreConstants; -using JsonWebTokenTypes = OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreConstants.JsonWebTokenTypes; namespace OpenIddict.Server.AspNetCore; @@ -33,8 +25,6 @@ public static partial class OpenIddictServerAspNetCoreHandlers * Authorization request extraction: */ ExtractGetOrPostRequest.Descriptor, - RestoreCachedRequestParameters.Descriptor, - CacheRequestParameters.Descriptor, /* * Authorization request handling: @@ -44,29 +34,43 @@ public static partial class OpenIddictServerAspNetCoreHandlers /* * Authorization response processing: */ - RemoveCachedRequest.Descriptor, AttachHttpResponseCode.Descriptor, AttachCacheControlHeader.Descriptor, + ProcessSelfRedirection.Descriptor, ProcessFormPostResponse.Descriptor, ProcessQueryResponse.Descriptor, ProcessFragmentResponse.Descriptor, ProcessPassthroughErrorResponse.Descriptor, ProcessStatusCodePagesErrorResponse.Descriptor, - ProcessLocalErrorResponse.Descriptor + ProcessLocalErrorResponse.Descriptor, + + /* + * Pushed authorization request extraction: + */ + ExtractPostRequest.Descriptor, + ValidateClientAuthenticationMethod.Descriptor, + ExtractBasicAuthenticationCredentials.Descriptor, + + /* + * Pushed authorization response processing: + */ + AttachHttpResponseCode.Descriptor, + AttachCacheControlHeader.Descriptor, + AttachWwwAuthenticateHeader.Descriptor, + ProcessJsonResponse.Descriptor ]); /// /// Contains the logic responsible for restoring cached requests from the request_id, if specified. /// Note: this handler is not used when the OpenID Connect request is not initially handled by ASP.NET Core. /// + [Obsolete("This event handler is obsolete and will be removed in a future version.")] public sealed class RestoreCachedRequestParameters : IOpenIddictServerHandler { - private readonly IDistributedCache _cache; - - public RestoreCachedRequestParameters() => throw new InvalidOperationException(SR.GetResourceString(SR.ID0116)); + public RestoreCachedRequestParameters() => throw new NotSupportedException(SR.GetResourceString(SR.ID0403)); public RestoreCachedRequestParameters(IDistributedCache cache) - => _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + => throw new NotSupportedException(SR.GetResourceString(SR.ID0403)); /// /// Gets the default descriptor definition assigned to this handler. @@ -81,92 +85,23 @@ public static partial class OpenIddictServerAspNetCoreHandlers .Build(); /// - public async ValueTask HandleAsync(ExtractAuthorizationRequestContext context) - { - if (context is null) - { - throw new ArgumentNullException(nameof(context)); - } - - Debug.Assert(context.Request is not null, SR.GetResourceString(SR.ID4008)); - - // If a request_id parameter can be found in the authorization request, - // restore the complete authorization request from the distributed cache. - - if (string.IsNullOrEmpty(context.Request.RequestId)) - { - return; - } - - // Note: the cache key is always prefixed with a specific marker - // to avoid collisions with the other types of cached payloads. - var token = await _cache.GetStringAsync(Cache.AuthorizationRequest + context.Request.RequestId); - if (token is null || !context.Options.JsonWebTokenHandler.CanReadToken(token)) - { - context.Logger.LogInformation(SR.GetResourceString(SR.ID6146), Parameters.RequestId); - - context.Reject( - error: Errors.InvalidRequest, - description: SR.FormatID2052(Parameters.RequestId), - uri: SR.FormatID8000(SR.ID2052)); - - return; - } - - var parameters = context.Options.TokenValidationParameters.Clone(); - parameters.ValidIssuer ??= (context.Options.Issuer ?? context.BaseUri)?.AbsoluteUri; - parameters.ValidAudience ??= parameters.ValidIssuer; - parameters.ValidTypes = [JsonWebTokenTypes.Private.AuthorizationRequest]; - - var result = await context.Options.JsonWebTokenHandler.ValidateTokenAsync(token, parameters); - if (!result.IsValid) - { - context.Logger.LogInformation(SR.GetResourceString(SR.ID6146), Parameters.RequestId); - - context.Reject( - error: Errors.InvalidRequest, - description: SR.FormatID2052(Parameters.RequestId), - uri: SR.FormatID8000(SR.ID2052)); - - return; - } - - using var document = JsonDocument.Parse( - Base64UrlEncoder.Decode(((JsonWebToken) result.SecurityToken).InnerToken.EncodedPayload)); - if (document.RootElement.ValueKind is not JsonValueKind.Object) - { - throw new InvalidOperationException(SR.GetResourceString(SR.ID0117)); - } - - // Restore the request parameters from the serialized payload. - foreach (var parameter in document.RootElement.EnumerateObject()) - { - if (!context.Request.HasParameter(parameter.Name)) - { - context.Request.AddParameter(parameter.Name, parameter.Value.Clone()); - } - } - } + public ValueTask HandleAsync(ExtractAuthorizationRequestContext context) + => throw new NotSupportedException(SR.GetResourceString(SR.ID0403)); } /// /// Contains the logic responsible for caching authorization requests, if applicable. /// Note: this handler is not used when the OpenID Connect request is not initially handled by ASP.NET Core. /// + [Obsolete("This event handler is obsolete and will be removed in a future version.")] public sealed class CacheRequestParameters : IOpenIddictServerHandler { - private readonly IDistributedCache _cache; - private readonly IOptionsMonitor _options; - - public CacheRequestParameters() => throw new InvalidOperationException(SR.GetResourceString(SR.ID0116)); + public CacheRequestParameters() => throw new NotSupportedException(SR.GetResourceString(SR.ID0403)); public CacheRequestParameters( IDistributedCache cache, IOptionsMonitor options) - { - _cache = cache ?? throw new ArgumentNullException(nameof(cache)); - _options = options ?? throw new ArgumentNullException(nameof(options)); - } + => throw new NotSupportedException(SR.GetResourceString(SR.ID0403)); /// /// Gets the default descriptor definition assigned to this handler. @@ -181,97 +116,21 @@ public static partial class OpenIddictServerAspNetCoreHandlers .Build(); /// - public async ValueTask HandleAsync(ExtractAuthorizationRequestContext context) - { - if (context is null) - { - throw new ArgumentNullException(nameof(context)); - } - - if (context is not { BaseUri.IsAbsoluteUri: true, RequestUri.IsAbsoluteUri: true }) - { - throw new InvalidOperationException(SR.GetResourceString(SR.ID0127)); - } - - Debug.Assert(context.Request is not null, SR.GetResourceString(SR.ID4008)); - - // 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. - var request = context.Transaction.GetHttpRequest() ?? - throw new InvalidOperationException(SR.GetResourceString(SR.ID0114)); - - // Don't cache the request if the request doesn't include any parameter. - // If a request_id parameter can be found in the authorization request, - // ignore the following logic to prevent an infinite redirect loop. - if (context.Request.Count is 0 || !string.IsNullOrEmpty(context.Request.RequestId)) - { - return; - } - - // Generate a 256-bit request identifier using a crypto-secure random number generator. - context.Request.RequestId = Base64UrlEncoder.Encode(OpenIddictHelpers.CreateRandomArray(size: 256)); - - // Build a list of claims matching the parameters extracted from the request. - // - // Note: in most cases, parameters should be representated as strings as requests are - // typically resolved from the query string or the request form, where parameters - // are natively represented as strings. However, requests can also be extracted from - // different places where they can be represented as complex JSON representations - // (e.g requests extracted from a JSON Web Token that may be encrypted and/or signed). - var claims = from parameter in context.Request.GetParameters() - let element = (JsonElement) parameter.Value - let type = element.ValueKind switch - { - JsonValueKind.String => ClaimValueTypes.String, - JsonValueKind.Number => ClaimValueTypes.Integer64, - JsonValueKind.True or JsonValueKind.False => ClaimValueTypes.Boolean, - JsonValueKind.Null or JsonValueKind.Undefined => JsonClaimValueTypes.JsonNull, - JsonValueKind.Array => JsonClaimValueTypes.JsonArray, - JsonValueKind.Object or _ => JsonClaimValueTypes.Json - } - select new Claim(parameter.Key, element.ToString()!, type); - - // Store the serialized authorization request parameters in the distributed cache. - var token = context.Options.JsonWebTokenHandler.CreateToken(new SecurityTokenDescriptor - { - Audience = (context.Options.Issuer ?? context.BaseUri)?.AbsoluteUri, - EncryptingCredentials = context.Options.EncryptionCredentials.First(), - Issuer = (context.Options.Issuer ?? context.BaseUri)?.AbsoluteUri, - SigningCredentials = context.Options.SigningCredentials.First(), - Subject = new ClaimsIdentity(claims, TokenValidationParameters.DefaultAuthenticationType), - TokenType = JsonWebTokenTypes.Private.AuthorizationRequest - }); - - // Note: the cache key is always prefixed with a specific marker - // to avoid collisions with the other types of cached payloads. - await _cache.SetStringAsync(Cache.AuthorizationRequest + context.Request.RequestId, - token, _options.CurrentValue.AuthorizationRequestCachingPolicy); - - // Create a new GET authorization request containing only the request_id parameter. - var location = QueryHelpers.AddQueryString( - uri: new UriBuilder(context.RequestUri) { Query = null }.Uri.AbsoluteUri, - name: Parameters.RequestId, - value: context.Request.RequestId); - - request.HttpContext.Response.Redirect(location); - - // Mark the response as handled to skip the rest of the pipeline. - context.HandleRequest(); - } + public ValueTask HandleAsync(ExtractAuthorizationRequestContext context) + => throw new NotSupportedException(SR.GetResourceString(SR.ID0403)); } /// /// Contains the logic responsible for removing cached authorization requests from the distributed cache. /// Note: this handler is not used when the OpenID Connect request is not initially handled by ASP.NET Core. /// + [Obsolete("This event handler is obsolete and will be removed in a future version.")] public sealed class RemoveCachedRequest : IOpenIddictServerHandler { - private readonly IDistributedCache _cache; - - public RemoveCachedRequest() => throw new InvalidOperationException(SR.GetResourceString(SR.ID0116)); + public RemoveCachedRequest() => throw new NotSupportedException(SR.GetResourceString(SR.ID0403)); public RemoveCachedRequest(IDistributedCache cache) - => _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + => throw new NotSupportedException(SR.GetResourceString(SR.ID0403)); /// /// Gets the default descriptor definition assigned to this handler. @@ -285,6 +144,28 @@ public static partial class OpenIddictServerAspNetCoreHandlers .SetType(OpenIddictServerHandlerType.BuiltIn) .Build(); + /// + public ValueTask HandleAsync(ApplyAuthorizationResponseContext context) + => throw new NotSupportedException(SR.GetResourceString(SR.ID0403)); + } + + /// + /// Contains the logic responsible for processing authorization responses requiring a self-redirection. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by ASP.NET Core. + /// + public sealed class ProcessSelfRedirection : IOpenIddictServerHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(250_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + /// public ValueTask HandleAsync(ApplyAuthorizationResponseContext context) { @@ -293,18 +174,47 @@ public static partial class OpenIddictServerAspNetCoreHandlers throw new ArgumentNullException(nameof(context)); } - if (string.IsNullOrEmpty(context.Request?.RequestId)) + if (context is not { BaseUri.IsAbsoluteUri: true, RequestUri.IsAbsoluteUri: true }) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0127)); + } + + if (string.IsNullOrEmpty(context.Response.RequestUri)) { return default; } - // Note: the ApplyAuthorizationResponse event is called for both successful - // and errored authorization responses but discrimination is not necessary here, - // as the authorization request must be removed from the distributed cache in both cases. + // 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. + var response = context.Transaction.GetHttpRequest()?.HttpContext.Response ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0114)); - // Note: the cache key is always prefixed with a specific marker - // to avoid collisions with the other types of cached payloads. - return new(_cache.RemoveAsync(Cache.AuthorizationRequest + context.Request.RequestId)); +#if SUPPORTS_MULTIPLE_VALUES_IN_QUERYHELPERS + var location = QueryHelpers.AddQueryString(context.RequestUri.GetLeftPart(UriPartial.Path), + from parameter in context.Response.GetParameters() + let values = (string?[]?) parameter.Value + where values is not null + from value in values + where !string.IsNullOrEmpty(value) + select KeyValuePair.Create(parameter.Key, value)); +#else + var location = context.RequestUri.GetLeftPart(UriPartial.Path); + + foreach (var (key, value) in + from parameter in context.Response.GetParameters() + let values = (string?[]?) parameter.Value + where values is not null + from value in values + where !string.IsNullOrEmpty(value) + select (parameter.Key, Value: value)) + { + location = QueryHelpers.AddQueryString(location, key, value); + } +#endif + response.Redirect(location); + context.HandleRequest(); + + return default; } } @@ -326,7 +236,7 @@ public static partial class OpenIddictServerAspNetCoreHandlers = OpenIddictServerHandlerDescriptor.CreateBuilder() .AddFilter() .UseSingletonHandler() - .SetOrder(250_000) + .SetOrder(ProcessSelfRedirection.Descriptor.Order + 1_000) .SetType(OpenIddictServerHandlerType.BuiltIn) .Build(); diff --git a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Session.cs b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Session.cs index b8e43285..ad4f82a7 100644 --- a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Session.cs +++ b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Session.cs @@ -5,19 +5,11 @@ */ using System.Collections.Immutable; -using System.Diagnostics; -using System.Security.Claims; -using System.Text.Json; using Microsoft.AspNetCore; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.JsonWebTokens; -using Microsoft.IdentityModel.Tokens; -using OpenIddict.Extensions; -using static OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreConstants; -using JsonWebTokenTypes = OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreConstants.JsonWebTokenTypes; namespace OpenIddict.Server.AspNetCore; @@ -30,8 +22,6 @@ public static partial class OpenIddictServerAspNetCoreHandlers * End-session request extraction: */ ExtractGetOrPostRequest.Descriptor, - RestoreCachedRequestParameters.Descriptor, - CacheRequestParameters.Descriptor, /* * End-session request handling: @@ -41,14 +31,14 @@ public static partial class OpenIddictServerAspNetCoreHandlers /* * End-session response processing: */ - RemoveCachedRequest.Descriptor, AttachHttpResponseCode.Descriptor, AttachCacheControlHeader.Descriptor, + ProcessSelfRedirection.Descriptor, + ProcessQueryResponse.Descriptor, ProcessHostRedirectionResponse.Descriptor, ProcessPassthroughErrorResponse.Descriptor, ProcessStatusCodePagesErrorResponse.Descriptor, ProcessLocalErrorResponse.Descriptor, - ProcessQueryResponse.Descriptor, ProcessEmptyResponse.Descriptor ]); @@ -56,14 +46,13 @@ public static partial class OpenIddictServerAspNetCoreHandlers /// Contains the logic responsible for restoring cached requests from the request_id, if specified. /// Note: this handler is not used when the OpenID Connect request is not initially handled by ASP.NET Core. /// + [Obsolete("This event handler is obsolete and will be removed in a future version.")] public sealed class RestoreCachedRequestParameters : IOpenIddictServerHandler { - private readonly IDistributedCache _cache; - - public RestoreCachedRequestParameters() => throw new InvalidOperationException(SR.GetResourceString(SR.ID0116)); + public RestoreCachedRequestParameters() => throw new NotSupportedException(SR.GetResourceString(SR.ID0403)); public RestoreCachedRequestParameters(IDistributedCache cache) - => _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + => throw new NotSupportedException(SR.GetResourceString(SR.ID0403)); /// /// Gets the default descriptor definition assigned to this handler. @@ -78,92 +67,23 @@ public static partial class OpenIddictServerAspNetCoreHandlers .Build(); /// - public async ValueTask HandleAsync(ExtractEndSessionRequestContext context) - { - if (context is null) - { - throw new ArgumentNullException(nameof(context)); - } - - Debug.Assert(context.Request is not null, SR.GetResourceString(SR.ID4008)); - - // If a request_id parameter can be found in the end session request, - // restore the complete end session request from the distributed cache. - - if (string.IsNullOrEmpty(context.Request.RequestId)) - { - return; - } - - // Note: the cache key is always prefixed with a specific marker - // to avoid collisions with the other types of cached payloads. - var token = await _cache.GetStringAsync(Cache.EndSessionRequest + context.Request.RequestId); - if (token is null || !context.Options.JsonWebTokenHandler.CanReadToken(token)) - { - context.Logger.LogInformation(SR.GetResourceString(SR.ID6150), Parameters.RequestId); - - context.Reject( - error: Errors.InvalidRequest, - description: SR.FormatID2052(Parameters.RequestId), - uri: SR.FormatID8000(SR.ID2052)); - - return; - } - - var parameters = context.Options.TokenValidationParameters.Clone(); - parameters.ValidIssuer ??= (context.Options.Issuer ?? context.BaseUri)?.AbsoluteUri; - parameters.ValidAudience ??= parameters.ValidIssuer; - parameters.ValidTypes = [JsonWebTokenTypes.Private.EndSessionRequest]; - - var result = await context.Options.JsonWebTokenHandler.ValidateTokenAsync(token, parameters); - if (!result.IsValid) - { - context.Logger.LogInformation(SR.GetResourceString(SR.ID6150), Parameters.RequestId); - - context.Reject( - error: Errors.InvalidRequest, - description: SR.FormatID2052(Parameters.RequestId), - uri: SR.FormatID8000(SR.ID2052)); - - return; - } - - using var document = JsonDocument.Parse( - Base64UrlEncoder.Decode(((JsonWebToken) result.SecurityToken).InnerToken.EncodedPayload)); - if (document.RootElement.ValueKind is not JsonValueKind.Object) - { - throw new InvalidOperationException(SR.GetResourceString(SR.ID0118)); - } - - // Restore the request parameters from the serialized payload. - foreach (var parameter in document.RootElement.EnumerateObject()) - { - if (!context.Request.HasParameter(parameter.Name)) - { - context.Request.AddParameter(parameter.Name, parameter.Value.Clone()); - } - } - } + public ValueTask HandleAsync(ExtractEndSessionRequestContext context) + => throw new NotSupportedException(SR.GetResourceString(SR.ID0403)); } /// /// Contains the logic responsible for caching end session requests, if applicable. /// Note: this handler is not used when the OpenID Connect request is not initially handled by ASP.NET Core. /// + [Obsolete("This event handler is obsolete and will be removed in a future version.")] public sealed class CacheRequestParameters : IOpenIddictServerHandler { - private readonly IDistributedCache _cache; - private readonly IOptionsMonitor _options; - - public CacheRequestParameters() => throw new InvalidOperationException(SR.GetResourceString(SR.ID0116)); + public CacheRequestParameters() => throw new NotSupportedException(SR.GetResourceString(SR.ID0403)); public CacheRequestParameters( IDistributedCache cache, IOptionsMonitor options) - { - _cache = cache ?? throw new ArgumentNullException(nameof(cache)); - _options = options ?? throw new ArgumentNullException(nameof(options)); - } + => throw new NotSupportedException(SR.GetResourceString(SR.ID0403)); /// /// Gets the default descriptor definition assigned to this handler. @@ -178,97 +98,21 @@ public static partial class OpenIddictServerAspNetCoreHandlers .Build(); /// - public async ValueTask HandleAsync(ExtractEndSessionRequestContext context) - { - if (context is null) - { - throw new ArgumentNullException(nameof(context)); - } - - if (context is not { BaseUri.IsAbsoluteUri: true, RequestUri.IsAbsoluteUri: true }) - { - throw new InvalidOperationException(SR.GetResourceString(SR.ID0127)); - } - - Debug.Assert(context.Request is not null, SR.GetResourceString(SR.ID4008)); - - // 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. - var request = context.Transaction.GetHttpRequest() ?? - throw new InvalidOperationException(SR.GetResourceString(SR.ID0114)); - - // Don't cache the request if the request doesn't include any parameter. - // If a request_id parameter can be found in the end session request, - // ignore the following logic to prevent an infinite redirect loop. - if (context.Request.Count is 0 || !string.IsNullOrEmpty(context.Request.RequestId)) - { - return; - } - - // Generate a 256-bit request identifier using a crypto-secure random number generator. - context.Request.RequestId = Base64UrlEncoder.Encode(OpenIddictHelpers.CreateRandomArray(size: 256)); - - // Build a list of claims matching the parameters extracted from the request. - // - // Note: in most cases, parameters should be representated as strings as requests are - // typically resolved from the query string or the request form, where parameters - // are natively represented as strings. However, requests can also be extracted from - // different places where they can be represented as complex JSON representations - // (e.g requests extracted from a JSON Web Token that may be encrypted and/or signed). - var claims = from parameter in context.Request.GetParameters() - let element = (JsonElement) parameter.Value - let type = element.ValueKind switch - { - JsonValueKind.String => ClaimValueTypes.String, - JsonValueKind.Number => ClaimValueTypes.Integer64, - JsonValueKind.True or JsonValueKind.False => ClaimValueTypes.Boolean, - JsonValueKind.Null or JsonValueKind.Undefined => JsonClaimValueTypes.JsonNull, - JsonValueKind.Array => JsonClaimValueTypes.JsonArray, - JsonValueKind.Object or _ => JsonClaimValueTypes.Json - } - select new Claim(parameter.Key, element.ToString()!, type); - - // Store the serialized end session request parameters in the distributed cache. - var token = context.Options.JsonWebTokenHandler.CreateToken(new SecurityTokenDescriptor - { - Audience = (context.Options.Issuer ?? context.BaseUri)?.AbsoluteUri, - EncryptingCredentials = context.Options.EncryptionCredentials.First(), - Issuer = (context.Options.Issuer ?? context.BaseUri)?.AbsoluteUri, - SigningCredentials = context.Options.SigningCredentials.First(), - Subject = new ClaimsIdentity(claims, TokenValidationParameters.DefaultAuthenticationType), - TokenType = JsonWebTokenTypes.Private.EndSessionRequest - }); - - // Note: the cache key is always prefixed with a specific marker - // to avoid collisions with the other types of cached payloads. - await _cache.SetStringAsync(Cache.EndSessionRequest + context.Request.RequestId, - token, _options.CurrentValue.EndSessionRequestCachingPolicy); - - // Create a new GET end session request containing only the request_id parameter. - var location = QueryHelpers.AddQueryString( - uri: new UriBuilder(context.RequestUri) { Query = null }.Uri.AbsoluteUri, - name: Parameters.RequestId, - value: context.Request.RequestId); - - request.HttpContext.Response.Redirect(location); - - // Mark the response as handled to skip the rest of the pipeline. - context.HandleRequest(); - } + public ValueTask HandleAsync(ExtractEndSessionRequestContext context) + => throw new NotSupportedException(SR.GetResourceString(SR.ID0403)); } /// /// Contains the logic responsible for removing cached end session requests from the distributed cache. /// Note: this handler is not used when the OpenID Connect request is not initially handled by ASP.NET Core. /// + [Obsolete("This event handler is obsolete and will be removed in a future version.")] public sealed class RemoveCachedRequest : IOpenIddictServerHandler { - private readonly IDistributedCache _cache; - - public RemoveCachedRequest() => throw new InvalidOperationException(SR.GetResourceString(SR.ID0116)); + public RemoveCachedRequest() => throw new NotSupportedException(SR.GetResourceString(SR.ID0403)); public RemoveCachedRequest(IDistributedCache cache) - => _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + => throw new NotSupportedException(SR.GetResourceString(SR.ID0403)); /// /// Gets the default descriptor definition assigned to this handler. @@ -282,6 +126,28 @@ public static partial class OpenIddictServerAspNetCoreHandlers .SetType(OpenIddictServerHandlerType.BuiltIn) .Build(); + /// + public ValueTask HandleAsync(ApplyEndSessionResponseContext context) + => throw new NotSupportedException(SR.GetResourceString(SR.ID0403)); + } + + /// + /// Contains the logic responsible for processing end session responses requiring a self-redirection. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by ASP.NET Core. + /// + public sealed class ProcessSelfRedirection : IOpenIddictServerHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(250_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + /// public ValueTask HandleAsync(ApplyEndSessionResponseContext context) { @@ -290,18 +156,47 @@ public static partial class OpenIddictServerAspNetCoreHandlers throw new ArgumentNullException(nameof(context)); } - if (string.IsNullOrEmpty(context.Request?.RequestId)) + if (context is not { BaseUri.IsAbsoluteUri: true, RequestUri.IsAbsoluteUri: true }) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0127)); + } + + if (string.IsNullOrEmpty(context.Response.RequestUri)) { return default; } - // Note: the ApplyEndSessionResponse event is called for both successful - // and errored end session responses but discrimination is not necessary here, - // as the end session request must be removed from the distributed cache in both cases. + // 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. + var response = context.Transaction.GetHttpRequest()?.HttpContext.Response ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0114)); + +#if SUPPORTS_MULTIPLE_VALUES_IN_QUERYHELPERS + var location = QueryHelpers.AddQueryString(context.RequestUri.GetLeftPart(UriPartial.Path), + from parameter in context.Response.GetParameters() + let values = (string?[]?) parameter.Value + where values is not null + from value in values + where !string.IsNullOrEmpty(value) + select KeyValuePair.Create(parameter.Key, value)); +#else + var location = context.RequestUri.GetLeftPart(UriPartial.Path); - // Note: the cache key is always prefixed with a specific marker - // to avoid collisions with the other types of cached payloads. - return new(_cache.RemoveAsync(Cache.EndSessionRequest + context.Request.RequestId)); + foreach (var (key, value) in + from parameter in context.Response.GetParameters() + let values = (string?[]?) parameter.Value + where values is not null + from value in values + where !string.IsNullOrEmpty(value) + select (parameter.Key, Value: value)) + { + location = QueryHelpers.AddQueryString(location, key, value); + } +#endif + response.Redirect(location); + context.HandleRequest(); + + return default; } } @@ -318,7 +213,7 @@ public static partial class OpenIddictServerAspNetCoreHandlers = OpenIddictServerHandlerDescriptor.CreateBuilder() .AddFilter() .UseSingletonHandler() - .SetOrder(250_000) + .SetOrder(ProcessSelfRedirection.Descriptor.Order + 1_000) .SetType(OpenIddictServerHandlerType.BuiltIn) .Build(); diff --git a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.cs b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.cs index 8f0423d4..32b60cfa 100644 --- a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.cs +++ b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.cs @@ -886,8 +886,27 @@ public static partial class OpenIddictServerAspNetCoreHandlers throw new ArgumentNullException(nameof(context)); } - context.SkipRequest(); + switch (context.EndpointType) + { + // When authorization request caching is enabled and the request doesn't contain a + // request_uri yet, do not enable the pass-through mode to allow OpenIddict to trigger + // a sign-in operation that will generate and attach a request token to the parameters. + case OpenIddictServerEndpointType.Authorization when + context.Options.EnableAuthorizationRequestCaching && + string.IsNullOrEmpty(context.Transaction.Request?.RequestUri): + return default; + + // When end session request caching is enabled and the request doesn't contain a + // request_uri yet, do not enable the pass-through mode to allow OpenIddict to trigger + // a sign-in operation that will generate and attach a request token to the parameters. + case OpenIddictServerEndpointType.EndSession when + context.Options.EnableEndSessionRequestCaching && + string.IsNullOrEmpty(context.Transaction.Request?.RequestUri): + return default; + } + + context.SkipRequest(); return default; } } @@ -926,6 +945,10 @@ public static partial class OpenIddictServerAspNetCoreHandlers response.StatusCode = (context.EndpointType, context.Transaction.Response.Error) switch { + // Note: for pushed authorization responses, the returned HTTP status code MUST be 201. + // See https://datatracker.ietf.org/doc/html/rfc9126#section-2.2 for more information. + (OpenIddictServerEndpointType.PushedAuthorization, null or { Length: 0 }) => 201, + // Note: the default code may be replaced by another handler (e.g when doing redirects). (_, null or { Length: 0 }) => 200, diff --git a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreOptions.cs b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreOptions.cs index f14f7093..c294e9fe 100644 --- a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreOptions.cs +++ b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreOptions.cs @@ -78,12 +78,14 @@ public sealed class OpenIddictServerAspNetCoreOptions : AuthenticationSchemeOpti /// Enabling this option is recommended when using external authentication providers /// or when large GET or POST OpenID Connect authorization requests support is required. /// + [Obsolete("This property is obsolete and will be removed in a future version.")] public bool EnableAuthorizationRequestCaching { get; set; } /// /// Gets or sets a boolean indicating whether requests received by the end session endpoint should be cached. /// When enabled, authorization requests are automatically stored in the distributed cache. /// + [Obsolete("This property is obsolete and will be removed in a future version.")] public bool EnableEndSessionRequestCaching { get; set; } /// @@ -106,6 +108,7 @@ public sealed class OpenIddictServerAspNetCoreOptions : AuthenticationSchemeOpti /// /// Gets or sets the caching policy used by the authorization endpoint. /// + [Obsolete("This property is obsolete and will be removed in a future version.")] public DistributedCacheEntryOptions AuthorizationRequestCachingPolicy { get; set; } = new() { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1), @@ -115,6 +118,7 @@ public sealed class OpenIddictServerAspNetCoreOptions : AuthenticationSchemeOpti /// /// Gets or sets the caching policy used by the end session endpoint. /// + [Obsolete("This property is obsolete and will be removed in a future version.")] public DistributedCacheEntryOptions EndSessionRequestCachingPolicy { get; set; } = new() { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1), diff --git a/src/OpenIddict.Server.DataProtection/OpenIddictServerDataProtectionBuilder.cs b/src/OpenIddict.Server.DataProtection/OpenIddictServerDataProtectionBuilder.cs index 53bfae55..5a75d800 100644 --- a/src/OpenIddict.Server.DataProtection/OpenIddictServerDataProtectionBuilder.cs +++ b/src/OpenIddict.Server.DataProtection/OpenIddictServerDataProtectionBuilder.cs @@ -99,6 +99,13 @@ public sealed class OpenIddictServerDataProtectionBuilder public OpenIddictServerDataProtectionBuilder PreferDefaultDeviceCodeFormat() => Configure(options => options.PreferDefaultDeviceCodeFormat = true); + /// + /// Configures OpenIddict to use the default token format (JWT) when issuing new request tokens. + /// + /// The instance. + public OpenIddictServerDataProtectionBuilder PreferDefaultRequestTokenFormat() + => Configure(options => options.PreferDefaultRequestTokenFormat = true); + /// /// Configures OpenIddict to use the default token format (JWT) when issuing new refresh tokens. /// diff --git a/src/OpenIddict.Server.DataProtection/OpenIddictServerDataProtectionConstants.cs b/src/OpenIddict.Server.DataProtection/OpenIddictServerDataProtectionConstants.cs index 7a208bf4..7fe42b02 100644 --- a/src/OpenIddict.Server.DataProtection/OpenIddictServerDataProtectionConstants.cs +++ b/src/OpenIddict.Server.DataProtection/OpenIddictServerDataProtectionConstants.cs @@ -27,6 +27,7 @@ public static class OpenIddictServerDataProtectionConstants public const string OriginalRedirectUri = ".original_redirect_uri"; public const string Presenters = ".presenters"; public const string RefreshTokenLifetime = ".refresh_token_lifetime"; + public const string RequestTokenLifetime = ".request_token_lifetime"; public const string Resources = ".resources"; public const string Scopes = ".scopes"; public const string UserCodeLifetime = ".user_code_lifetime"; @@ -44,6 +45,7 @@ public static class OpenIddictServerDataProtectionConstants public const string AccessToken = "AccessTokenFormat"; public const string AuthorizationCode = "AuthorizationCodeFormat"; public const string DeviceCode = "DeviceCodeFormat"; + public const string RequestToken = "RequestTokenFormat"; public const string RefreshToken = "RefreshTokenFormat"; public const string UserCode = "UserCodeFormat"; } diff --git a/src/OpenIddict.Server.DataProtection/OpenIddictServerDataProtectionFormatter.cs b/src/OpenIddict.Server.DataProtection/OpenIddictServerDataProtectionFormatter.cs index 0505fb7e..57eb3a40 100644 --- a/src/OpenIddict.Server.DataProtection/OpenIddictServerDataProtectionFormatter.cs +++ b/src/OpenIddict.Server.DataProtection/OpenIddictServerDataProtectionFormatter.cs @@ -37,6 +37,7 @@ public sealed class OpenIddictServerDataProtectionFormatter : IOpenIddictServerD .SetClaim(Claims.Private.DeviceCodeLifetime, GetIntegerProperty(properties, Properties.DeviceCodeLifetime)) .SetClaim(Claims.Private.IdentityTokenLifetime, GetIntegerProperty(properties, Properties.IdentityTokenLifetime)) .SetClaim(Claims.Private.RefreshTokenLifetime, GetIntegerProperty(properties, Properties.RefreshTokenLifetime)) + .SetClaim(Claims.Private.RequestTokenLifetime, GetIntegerProperty(properties, Properties.RequestTokenLifetime)) .SetClaim(Claims.Private.UserCodeLifetime, GetIntegerProperty(properties, Properties.UserCodeLifetime)) .SetClaims(Claims.Private.Audience, GetJsonProperty(properties, Properties.Audiences)) @@ -214,6 +215,7 @@ public sealed class OpenIddictServerDataProtectionFormatter : IOpenIddictServerD SetProperty(properties, Properties.DeviceCodeLifetime, principal.GetClaim(Claims.Private.DeviceCodeLifetime)); SetProperty(properties, Properties.IdentityTokenLifetime, principal.GetClaim(Claims.Private.IdentityTokenLifetime)); SetProperty(properties, Properties.RefreshTokenLifetime, principal.GetClaim(Claims.Private.RefreshTokenLifetime)); + SetProperty(properties, Properties.RequestTokenLifetime, principal.GetClaim(Claims.Private.RequestTokenLifetime)); SetProperty(properties, Properties.UserCodeLifetime, principal.GetClaim(Claims.Private.UserCodeLifetime)); SetProperty(properties, Properties.CodeChallenge, principal.GetClaim(Claims.Private.CodeChallenge)); @@ -249,6 +251,7 @@ public sealed class OpenIddictServerDataProtectionFormatter : IOpenIddictServerD Claims.Private.Presenter or Claims.Private.RedirectUri or Claims.Private.RefreshTokenLifetime or + Claims.Private.RequestTokenLifetime or Claims.Private.Resource or Claims.Private.Scope or Claims.Private.TokenId or diff --git a/src/OpenIddict.Server.DataProtection/OpenIddictServerDataProtectionHandlers.Protection.cs b/src/OpenIddict.Server.DataProtection/OpenIddictServerDataProtectionHandlers.Protection.cs index 1581687a..0e2de127 100644 --- a/src/OpenIddict.Server.DataProtection/OpenIddictServerDataProtectionHandlers.Protection.cs +++ b/src/OpenIddict.Server.DataProtection/OpenIddictServerDataProtectionHandlers.Protection.cs @@ -113,35 +113,48 @@ public static partial class OpenIddictServerDataProtectionHandlers ValidateToken(TokenTypeHints.AccessToken) ?? ValidateToken(TokenTypeHints.RefreshToken) ?? ValidateToken(TokenTypeHints.DeviceCode) ?? - ValidateToken(TokenTypeHints.UserCode), + ValidateToken(TokenTypeHints.UserCode) ?? + ValidateToken(TokenTypeHints.Private.RequestToken), TokenTypeHints.DeviceCode => ValidateToken(TokenTypeHints.DeviceCode) ?? ValidateToken(TokenTypeHints.AccessToken) ?? ValidateToken(TokenTypeHints.RefreshToken) ?? ValidateToken(TokenTypeHints.AuthorizationCode) ?? - ValidateToken(TokenTypeHints.UserCode), + ValidateToken(TokenTypeHints.UserCode) ?? + ValidateToken(TokenTypeHints.Private.RequestToken), TokenTypeHints.RefreshToken => ValidateToken(TokenTypeHints.RefreshToken) ?? ValidateToken(TokenTypeHints.AccessToken) ?? ValidateToken(TokenTypeHints.AuthorizationCode) ?? ValidateToken(TokenTypeHints.DeviceCode) ?? - ValidateToken(TokenTypeHints.UserCode), + ValidateToken(TokenTypeHints.UserCode) ?? + ValidateToken(TokenTypeHints.Private.RequestToken), TokenTypeHints.UserCode => ValidateToken(TokenTypeHints.UserCode) ?? ValidateToken(TokenTypeHints.AccessToken) ?? ValidateToken(TokenTypeHints.RefreshToken) ?? ValidateToken(TokenTypeHints.AuthorizationCode) ?? - ValidateToken(TokenTypeHints.DeviceCode), + ValidateToken(TokenTypeHints.DeviceCode) ?? + ValidateToken(TokenTypeHints.Private.RequestToken), + + TokenTypeHints.Private.RequestToken => + ValidateToken(TokenTypeHints.AccessToken) ?? + ValidateToken(TokenTypeHints.RefreshToken) ?? + ValidateToken(TokenTypeHints.AuthorizationCode) ?? + ValidateToken(TokenTypeHints.DeviceCode) ?? + ValidateToken(TokenTypeHints.UserCode) ?? + ValidateToken(TokenTypeHints.Private.RequestToken), _ => ValidateToken(TokenTypeHints.AccessToken) ?? ValidateToken(TokenTypeHints.RefreshToken) ?? ValidateToken(TokenTypeHints.AuthorizationCode) ?? ValidateToken(TokenTypeHints.DeviceCode) ?? - ValidateToken(TokenTypeHints.UserCode), + ValidateToken(TokenTypeHints.UserCode) ?? + ValidateToken(TokenTypeHints.Private.RequestToken), }, // If a single valid token type was set, ignore the specified token type hint. @@ -153,6 +166,8 @@ public static partial class OpenIddictServerDataProtectionHandlers TokenTypeHints.DeviceCode => ValidateToken(TokenTypeHints.DeviceCode), TokenTypeHints.UserCode => ValidateToken(TokenTypeHints.UserCode), + TokenTypeHints.Private.RequestToken => ValidateToken(TokenTypeHints.Private.RequestToken), + _ => null // The token type is not supported by the Data Protection integration (e.g identity tokens). }, @@ -169,6 +184,8 @@ public static partial class OpenIddictServerDataProtectionHandlers TokenTypeHints.DeviceCode => 4, TokenTypeHints.UserCode => 5, + TokenTypeHints.Private.RequestToken => 6, + _ => int.MaxValue }) .Select(type => type switch @@ -179,6 +196,8 @@ public static partial class OpenIddictServerDataProtectionHandlers TokenTypeHints.DeviceCode => ValidateToken(TokenTypeHints.DeviceCode), TokenTypeHints.UserCode => ValidateToken(TokenTypeHints.UserCode), + TokenTypeHints.Private.RequestToken => ValidateToken(TokenTypeHints.Private.RequestToken), + _ => null // The token type is not supported by the Data Protection integration (e.g identity tokens). }) .Where(static principal => principal is not null) @@ -234,6 +253,11 @@ public static partial class OpenIddictServerDataProtectionHandlers (TokenTypeHints.UserCode, false) => [Handlers.Server, Formats.UserCode, Schemes.Server], + (TokenTypeHints.Private.RequestToken, true) + => [Handlers.Server, Formats.RequestToken, Features.ReferenceTokens, Schemes.Server], + (TokenTypeHints.Private.RequestToken, false) + => [Handlers.Server, Formats.RequestToken, Schemes.Server], + _ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0003)) }); @@ -309,6 +333,9 @@ public static partial class OpenIddictServerDataProtectionHandlers TokenTypeHints.UserCode when !_options.CurrentValue.PreferDefaultUserCodeFormat => TokenFormats.Private.DataProtection, + TokenTypeHints.Private.RequestToken when !_options.CurrentValue.PreferDefaultRequestTokenFormat + => TokenFormats.Private.DataProtection, + _ => context.TokenFormat // Don't override the format if the token type is not supported. }; @@ -382,6 +409,11 @@ public static partial class OpenIddictServerDataProtectionHandlers (TokenTypeHints.UserCode, false) => [Handlers.Server, Formats.UserCode, Schemes.Server], + (TokenTypeHints.Private.RequestToken, true) + => [Handlers.Server, Formats.RequestToken, Features.ReferenceTokens, Schemes.Server], + (TokenTypeHints.Private.RequestToken, false) + => [Handlers.Server, Formats.RequestToken, Schemes.Server], + _ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0003)) }); diff --git a/src/OpenIddict.Server.DataProtection/OpenIddictServerDataProtectionOptions.cs b/src/OpenIddict.Server.DataProtection/OpenIddictServerDataProtectionOptions.cs index 027820a1..e267a78d 100644 --- a/src/OpenIddict.Server.DataProtection/OpenIddictServerDataProtectionOptions.cs +++ b/src/OpenIddict.Server.DataProtection/OpenIddictServerDataProtectionOptions.cs @@ -46,6 +46,12 @@ public sealed class OpenIddictServerDataProtectionOptions /// public bool PreferDefaultDeviceCodeFormat { get; set; } + /// + /// Gets or sets a boolean indicating whether the default user code format should be used when issuing + /// new request tokens. This property is set to by default. + /// + public bool PreferDefaultRequestTokenFormat { get; set; } + /// /// Gets or sets a boolean indicating whether the default refresh token format should be /// used when issuing new refresh tokens. This property is set to by default. diff --git a/src/OpenIddict.Server.Owin/OpenIddictServerOwinBuilder.cs b/src/OpenIddict.Server.Owin/OpenIddictServerOwinBuilder.cs index 848b9cdf..dd209210 100644 --- a/src/OpenIddict.Server.Owin/OpenIddictServerOwinBuilder.cs +++ b/src/OpenIddict.Server.Owin/OpenIddictServerOwinBuilder.cs @@ -6,6 +6,7 @@ using System.ComponentModel; using Microsoft.Extensions.Caching.Distributed; +using OpenIddict.Server; using OpenIddict.Server.Owin; using Owin; @@ -124,16 +125,26 @@ public sealed class OpenIddictServerOwinBuilder /// OpenID Connect authorization requests support is required. /// /// The instance. + [Obsolete("This method is obsolete and will be removed in a future version.")] public OpenIddictServerOwinBuilder EnableAuthorizationRequestCaching() - => Configure(options => options.EnableAuthorizationRequestCaching = true); + { + Services.Configure(options => options.EnableAuthorizationRequestCaching = true); + + return this; + } /// /// Enables end session request caching, so that end session requests /// are automatically stored in the distributed cache. /// /// The instance. + [Obsolete("This method is obsolete and will be removed in a future version.")] public OpenIddictServerOwinBuilder EnableEndSessionRequestCaching() - => Configure(options => options.EnableEndSessionRequestCaching = true); + { + Services.Configure(options => options.EnableEndSessionRequestCaching = true); + + return this; + } /// /// Suppresses indentation for the JSON responses returned by the OWIN host. @@ -163,6 +174,7 @@ public sealed class OpenIddictServerOwinBuilder /// /// The caching policy. /// The instance. + [Obsolete("This method is obsolete and will be removed in a future version.")] public OpenIddictServerOwinBuilder SetAuthorizationRequestCachingPolicy(DistributedCacheEntryOptions policy) { if (policy is null) @@ -179,6 +191,7 @@ public sealed class OpenIddictServerOwinBuilder /// /// The caching policy. /// The instance. + [Obsolete("This method is obsolete and will be removed in a future version.")] public OpenIddictServerOwinBuilder SetEndSessionRequestCachingPolicy(DistributedCacheEntryOptions policy) { if (policy is null) diff --git a/src/OpenIddict.Server.Owin/OpenIddictServerOwinConstants.cs b/src/OpenIddict.Server.Owin/OpenIddictServerOwinConstants.cs index 7f6ea11c..4d617422 100644 --- a/src/OpenIddict.Server.Owin/OpenIddictServerOwinConstants.cs +++ b/src/OpenIddict.Server.Owin/OpenIddictServerOwinConstants.cs @@ -11,6 +11,7 @@ namespace OpenIddict.Server.Owin; /// public static class OpenIddictServerOwinConstants { + [Obsolete("This class is obsolete and will be removed in a future version.")] public static class Cache { public const string AuthorizationRequest = "openiddict-authorization-request:"; @@ -28,6 +29,7 @@ public static class OpenIddictServerOwinConstants public const string WwwAuthenticate = "WWW-Authenticate"; } + [Obsolete("This class is obsolete and will be removed in a future version.")] public static class JsonWebTokenTypes { public static class Private @@ -67,6 +69,7 @@ public static class OpenIddictServerOwinConstants public const string DeviceCode = "device_code"; public const string IdentityToken = "id_token"; public const string RefreshToken = "refresh_token"; + public const string RequestToken = "request_token"; public const string UserCode = "user_code"; } } diff --git a/src/OpenIddict.Server.Owin/OpenIddictServerOwinExtensions.cs b/src/OpenIddict.Server.Owin/OpenIddictServerOwinExtensions.cs index 6a89f542..de3734b1 100644 --- a/src/OpenIddict.Server.Owin/OpenIddictServerOwinExtensions.cs +++ b/src/OpenIddict.Server.Owin/OpenIddictServerOwinExtensions.cs @@ -41,9 +41,11 @@ public static class OpenIddictServerOwinExtensions builder.Services.TryAdd(OpenIddictServerOwinHandlers.DefaultHandlers.Select(descriptor => descriptor.ServiceDescriptor)); // Register the built-in filters used by the default OpenIddict OWIN server event handlers. +#pragma warning disable CS0618 builder.Services.TryAddSingleton(); - builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); +#pragma warning restore CS0618 + builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); diff --git a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandler.cs b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandler.cs index eb70557f..80f8392c 100644 --- a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandler.cs +++ b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandler.cs @@ -162,6 +162,7 @@ public sealed class OpenIddictServerOwinHandler : AuthenticationHandler context.UserCodePrincipal, OpenIddictServerEndpointType.Introspection or OpenIddictServerEndpointType.Revocation - => context.AccessTokenPrincipal ?? - context.RefreshTokenPrincipal ?? - context.IdentityTokenPrincipal ?? - context.AuthorizationCodePrincipal ?? - context.DeviceCodePrincipal ?? - context.UserCodePrincipal, + => context.GenericTokenPrincipal, OpenIddictServerEndpointType.Token when context.Request.IsAuthorizationCodeGrantType() => context.AuthorizationCodePrincipal, @@ -240,6 +236,11 @@ public sealed class OpenIddictServerOwinHandler : AuthenticationHandler /// Represents a filter that excludes the associated handlers if authorization request caching was not enabled. /// + [Obsolete("This filter is obsolete and will be removed in a future version.")] public sealed class RequireAuthorizationRequestCachingEnabled : IOpenIddictServerHandlerFilter { private readonly IOptionsMonitor _options; @@ -62,6 +63,7 @@ public static class OpenIddictServerOwinHandlerFilters /// /// Represents a filter that excludes the associated handlers if end session request caching was not enabled. /// + [Obsolete("This filter is obsolete and will be removed in a future version.")] public sealed class RequireEndSessionRequestCachingEnabled : IOpenIddictServerHandlerFilter { private readonly IOptionsMonitor _options; diff --git a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Authentication.cs b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Authentication.cs index 3b02908a..f7a7f7d3 100644 --- a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Authentication.cs +++ b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Authentication.cs @@ -5,20 +5,13 @@ */ using System.Collections.Immutable; -using System.Diagnostics; -using System.Security.Claims; using System.Text; using System.Text.Encodings.Web; -using System.Text.Json; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.JsonWebTokens; -using Microsoft.IdentityModel.Tokens; -using OpenIddict.Extensions; using Owin; using static OpenIddict.Server.Owin.OpenIddictServerOwinConstants; -using JsonWebTokenTypes = OpenIddict.Server.Owin.OpenIddictServerOwinConstants.JsonWebTokenTypes; namespace OpenIddict.Server.Owin; @@ -31,8 +24,6 @@ public static partial class OpenIddictServerOwinHandlers * Authorization request extraction: */ ExtractGetOrPostRequest.Descriptor, - RestoreCachedRequestParameters.Descriptor, - CacheRequestParameters.Descriptor, /* * Authorization request handling: @@ -42,30 +33,46 @@ public static partial class OpenIddictServerOwinHandlers /* * Authorization response processing: */ - RemoveCachedRequest.Descriptor, AttachHttpResponseCode.Descriptor, AttachOwinResponseChallenge.Descriptor, SuppressFormsAuthenticationRedirect.Descriptor, AttachCacheControlHeader.Descriptor, + ProcessSelfRedirection.Descriptor, ProcessFormPostResponse.Descriptor, ProcessQueryResponse.Descriptor, ProcessFragmentResponse.Descriptor, ProcessPassthroughErrorResponse.Descriptor, - ProcessLocalErrorResponse.Descriptor + ProcessLocalErrorResponse.Descriptor, + + /* + * Pushed authorization request extraction: + */ + ExtractPostRequest.Descriptor, + ValidateClientAuthenticationMethod.Descriptor, + ExtractBasicAuthenticationCredentials.Descriptor, + + /* + * Pushed authorization response processing: + */ + AttachHttpResponseCode.Descriptor, + AttachOwinResponseChallenge.Descriptor, + SuppressFormsAuthenticationRedirect.Descriptor, + AttachCacheControlHeader.Descriptor, + AttachWwwAuthenticateHeader.Descriptor, + ProcessJsonResponse.Descriptor, ]); /// /// Contains the logic responsible for restoring cached requests from the request_id, if specified. /// Note: this handler is not used when the OpenID Connect request is not initially handled by OWIN. /// + [Obsolete("This event handler is obsolete and will be removed in a future version.")] public sealed class RestoreCachedRequestParameters : IOpenIddictServerHandler { - private readonly IDistributedCache _cache; - - public RestoreCachedRequestParameters() => throw new InvalidOperationException(SR.GetResourceString(SR.ID0116)); + public RestoreCachedRequestParameters() => throw new NotSupportedException(SR.GetResourceString(SR.ID0403)); public RestoreCachedRequestParameters(IDistributedCache cache) - => _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + => throw new NotSupportedException(SR.GetResourceString(SR.ID0403)); /// /// Gets the default descriptor definition assigned to this handler. @@ -80,92 +87,23 @@ public static partial class OpenIddictServerOwinHandlers .Build(); /// - public async ValueTask HandleAsync(ExtractAuthorizationRequestContext context) - { - if (context is null) - { - throw new ArgumentNullException(nameof(context)); - } - - Debug.Assert(context.Request is not null, SR.GetResourceString(SR.ID4008)); - - // If a request_id parameter can be found in the authorization request, - // restore the complete authorization request from the distributed cache. - - if (string.IsNullOrEmpty(context.Request.RequestId)) - { - return; - } - - // Note: the cache key is always prefixed with a specific marker - // to avoid collisions with the other types of cached payloads. - var token = await _cache.GetStringAsync(Cache.AuthorizationRequest + context.Request.RequestId); - if (token is null || !context.Options.JsonWebTokenHandler.CanReadToken(token)) - { - context.Logger.LogInformation(SR.GetResourceString(SR.ID6146), Parameters.RequestId); - - context.Reject( - error: Errors.InvalidRequest, - description: SR.FormatID2052(Parameters.RequestId), - uri: SR.FormatID8000(SR.ID2052)); - - return; - } - - var parameters = context.Options.TokenValidationParameters.Clone(); - parameters.ValidIssuer ??= (context.Options.Issuer ?? context.BaseUri)?.AbsoluteUri; - parameters.ValidAudience ??= parameters.ValidIssuer; - parameters.ValidTypes = [JsonWebTokenTypes.Private.AuthorizationRequest]; - - var result = await context.Options.JsonWebTokenHandler.ValidateTokenAsync(token, parameters); - if (!result.IsValid) - { - context.Logger.LogInformation(SR.GetResourceString(SR.ID6146), Parameters.RequestId); - - context.Reject( - error: Errors.InvalidRequest, - description: SR.FormatID2052(Parameters.RequestId), - uri: SR.FormatID8000(SR.ID2052)); - - return; - } - - using var document = JsonDocument.Parse( - Base64UrlEncoder.Decode(((JsonWebToken) result.SecurityToken).InnerToken.EncodedPayload)); - if (document.RootElement.ValueKind is not JsonValueKind.Object) - { - throw new InvalidOperationException(SR.GetResourceString(SR.ID0117)); - } - - // Restore the request parameters from the serialized payload. - foreach (var parameter in document.RootElement.EnumerateObject()) - { - if (!context.Request.HasParameter(parameter.Name)) - { - context.Request.AddParameter(parameter.Name, parameter.Value.Clone()); - } - } - } + public ValueTask HandleAsync(ExtractAuthorizationRequestContext context) + => throw new NotSupportedException(SR.GetResourceString(SR.ID0403)); } /// /// Contains the logic responsible for caching authorization requests, if applicable. /// Note: this handler is not used when the OpenID Connect request is not initially handled by OWIN. /// + [Obsolete("This event handler is obsolete and will be removed in a future version.")] public sealed class CacheRequestParameters : IOpenIddictServerHandler { - private readonly IDistributedCache _cache; - private readonly IOptionsMonitor _options; - - public CacheRequestParameters() => throw new InvalidOperationException(SR.GetResourceString(SR.ID0116)); + public CacheRequestParameters() => throw new NotSupportedException(SR.GetResourceString(SR.ID0403)); public CacheRequestParameters( IDistributedCache cache, IOptionsMonitor options) - { - _cache = cache ?? throw new ArgumentNullException(nameof(cache)); - _options = options ?? throw new ArgumentNullException(nameof(options)); - } + => throw new NotSupportedException(SR.GetResourceString(SR.ID0403)); /// /// Gets the default descriptor definition assigned to this handler. @@ -180,97 +118,21 @@ public static partial class OpenIddictServerOwinHandlers .Build(); /// - public async ValueTask HandleAsync(ExtractAuthorizationRequestContext context) - { - if (context is null) - { - throw new ArgumentNullException(nameof(context)); - } - - if (context is not { BaseUri.IsAbsoluteUri: true, RequestUri.IsAbsoluteUri: true }) - { - throw new InvalidOperationException(SR.GetResourceString(SR.ID0127)); - } - - Debug.Assert(context.Request is not null, SR.GetResourceString(SR.ID4008)); - - // This handler only applies to OWIN requests. If The OWIN request cannot be resolved, - // this may indicate that the request was incorrectly processed by another server stack. - var request = context.Transaction.GetOwinRequest() ?? - throw new InvalidOperationException(SR.GetResourceString(SR.ID0120)); - - // Don't cache the request if the request doesn't include any parameter. - // If a request_id parameter can be found in the authorization request, - // ignore the following logic to prevent an infinite redirect loop. - if (context.Request.Count is 0 || !string.IsNullOrEmpty(context.Request.RequestId)) - { - return; - } - - // Generate a 256-bit request identifier using a crypto-secure random number generator. - context.Request.RequestId = Base64UrlEncoder.Encode(OpenIddictHelpers.CreateRandomArray(size: 256)); - - // Build a list of claims matching the parameters extracted from the request. - // - // Note: in most cases, parameters should be representated as strings as requests are - // typically resolved from the query string or the request form, where parameters - // are natively represented as strings. However, requests can also be extracted from - // different places where they can be represented as complex JSON representations - // (e.g requests extracted from a JSON Web Token that may be encrypted and/or signed). - var claims = from parameter in context.Request.GetParameters() - let element = (JsonElement) parameter.Value - let type = element.ValueKind switch - { - JsonValueKind.String => ClaimValueTypes.String, - JsonValueKind.Number => ClaimValueTypes.Integer64, - JsonValueKind.True or JsonValueKind.False => ClaimValueTypes.Boolean, - JsonValueKind.Null or JsonValueKind.Undefined => JsonClaimValueTypes.JsonNull, - JsonValueKind.Array => JsonClaimValueTypes.JsonArray, - JsonValueKind.Object or _ => JsonClaimValueTypes.Json - } - select new Claim(parameter.Key, element.ToString()!, type); - - // Store the serialized authorization request parameters in the distributed cache. - var token = context.Options.JsonWebTokenHandler.CreateToken(new SecurityTokenDescriptor - { - Audience = (context.Options.Issuer ?? context.BaseUri)?.AbsoluteUri, - EncryptingCredentials = context.Options.EncryptionCredentials.First(), - Issuer = (context.Options.Issuer ?? context.BaseUri)?.AbsoluteUri, - SigningCredentials = context.Options.SigningCredentials.First(), - Subject = new ClaimsIdentity(claims, TokenValidationParameters.DefaultAuthenticationType), - TokenType = JsonWebTokenTypes.Private.AuthorizationRequest - }); - - // Note: the cache key is always prefixed with a specific marker - // to avoid collisions with the other types of cached payloads. - await _cache.SetStringAsync(Cache.AuthorizationRequest + context.Request.RequestId, - token, _options.CurrentValue.AuthorizationRequestCachingPolicy); - - // Create a new GET authorization request containing only the request_id parameter. - var location = WebUtilities.AddQueryString( - uri: new UriBuilder(context.RequestUri) { Query = null }.Uri.AbsoluteUri, - name: Parameters.RequestId, - value: context.Request.RequestId); - - request.Context.Response.Redirect(location); - - // Mark the response as handled to skip the rest of the pipeline. - context.HandleRequest(); - } + public ValueTask HandleAsync(ExtractAuthorizationRequestContext context) + => throw new NotSupportedException(SR.GetResourceString(SR.ID0403)); } /// /// Contains the logic responsible for removing cached authorization requests from the distributed cache. /// Note: this handler is not used when the OpenID Connect request is not initially handled by OWIN. /// + [Obsolete("This event handler is obsolete and will be removed in a future version.")] public sealed class RemoveCachedRequest : IOpenIddictServerHandler { - private readonly IDistributedCache _cache; - - public RemoveCachedRequest() => throw new InvalidOperationException(SR.GetResourceString(SR.ID0116)); + public RemoveCachedRequest() => throw new NotSupportedException(SR.GetResourceString(SR.ID0403)); public RemoveCachedRequest(IDistributedCache cache) - => _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + => throw new NotSupportedException(SR.GetResourceString(SR.ID0403)); /// /// Gets the default descriptor definition assigned to this handler. @@ -284,6 +146,28 @@ public static partial class OpenIddictServerOwinHandlers .SetType(OpenIddictServerHandlerType.BuiltIn) .Build(); + /// + public ValueTask HandleAsync(ApplyAuthorizationResponseContext context) + => throw new NotSupportedException(SR.GetResourceString(SR.ID0403)); + } + + /// + /// Contains the logic responsible for processing authorization responses requiring a self-redirection. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by OWIN. + /// + public sealed class ProcessSelfRedirection : IOpenIddictServerHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(250_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + /// public ValueTask HandleAsync(ApplyAuthorizationResponseContext context) { @@ -292,18 +176,37 @@ public static partial class OpenIddictServerOwinHandlers throw new ArgumentNullException(nameof(context)); } - if (string.IsNullOrEmpty(context.Request?.RequestId)) + if (context is not { BaseUri.IsAbsoluteUri: true, RequestUri.IsAbsoluteUri: true }) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0127)); + } + + if (string.IsNullOrEmpty(context.Response.RequestUri)) { return default; } - // Note: the ApplyAuthorizationResponse event is called for both successful - // and errored authorization responses but discrimination is not necessary here, - // as the authorization request must be removed from the distributed cache in both cases. + // 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. + var response = context.Transaction.GetOwinRequest()?.Context.Response ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0120)); - // Note: the cache key is always prefixed with a specific marker - // to avoid collisions with the other types of cached payloads. - return new(_cache.RemoveAsync(Cache.AuthorizationRequest + context.Request.RequestId)); + var location = context.RequestUri.GetLeftPart(UriPartial.Path); + foreach (var (key, value) in + from parameter in context.Response.GetParameters() + let values = (string?[]?) parameter.Value + where values is not null + from value in values + where !string.IsNullOrEmpty(value) + select (parameter.Key, Value: value)) + { + location = WebUtilities.AddQueryString(location, key, value); + } + + response.Redirect(location); + context.HandleRequest(); + + return default; } } @@ -325,7 +228,7 @@ public static partial class OpenIddictServerOwinHandlers = OpenIddictServerHandlerDescriptor.CreateBuilder() .AddFilter() .UseSingletonHandler() - .SetOrder(250_000) + .SetOrder(ProcessSelfRedirection.Descriptor.Order) .SetType(OpenIddictServerHandlerType.BuiltIn) .Build(); diff --git a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Session.cs b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Session.cs index 30cdc913..201497d5 100644 --- a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Session.cs +++ b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Session.cs @@ -5,18 +5,10 @@ */ using System.Collections.Immutable; -using System.Diagnostics; -using System.Security.Claims; -using System.Text.Json; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.JsonWebTokens; -using Microsoft.IdentityModel.Tokens; -using OpenIddict.Extensions; using Owin; -using static OpenIddict.Server.Owin.OpenIddictServerOwinConstants; -using JsonWebTokenTypes = OpenIddict.Server.Owin.OpenIddictServerOwinConstants.JsonWebTokenTypes; namespace OpenIddict.Server.Owin; @@ -29,8 +21,6 @@ public static partial class OpenIddictServerOwinHandlers * End-session request extraction: */ ExtractGetOrPostRequest.Descriptor, - RestoreCachedRequestParameters.Descriptor, - CacheRequestParameters.Descriptor, /* * End-session request handling: @@ -40,15 +30,15 @@ public static partial class OpenIddictServerOwinHandlers /* * End-session response processing: */ - RemoveCachedRequest.Descriptor, AttachHttpResponseCode.Descriptor, AttachOwinResponseChallenge.Descriptor, SuppressFormsAuthenticationRedirect.Descriptor, AttachCacheControlHeader.Descriptor, + ProcessSelfRedirection.Descriptor, + ProcessQueryResponse.Descriptor, ProcessHostRedirectionResponse.Descriptor, ProcessPassthroughErrorResponse.Descriptor, ProcessLocalErrorResponse.Descriptor, - ProcessQueryResponse.Descriptor, ProcessEmptyResponse.Descriptor ]); @@ -56,14 +46,13 @@ public static partial class OpenIddictServerOwinHandlers /// Contains the logic responsible for restoring cached requests from the request_id, if specified. /// Note: this handler is not used when the OpenID Connect request is not initially handled by OWIN. /// + [Obsolete("This event handler is obsolete and will be removed in a future version.")] public sealed class RestoreCachedRequestParameters : IOpenIddictServerHandler { - private readonly IDistributedCache _cache; - - public RestoreCachedRequestParameters() => throw new InvalidOperationException(SR.GetResourceString(SR.ID0116)); + public RestoreCachedRequestParameters() => throw new NotSupportedException(SR.GetResourceString(SR.ID0403)); public RestoreCachedRequestParameters(IDistributedCache cache) - => _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + => throw new NotSupportedException(SR.GetResourceString(SR.ID0403)); /// /// Gets the default descriptor definition assigned to this handler. @@ -78,92 +67,23 @@ public static partial class OpenIddictServerOwinHandlers .Build(); /// - public async ValueTask HandleAsync(ExtractEndSessionRequestContext context) - { - if (context is null) - { - throw new ArgumentNullException(nameof(context)); - } - - Debug.Assert(context.Request is not null, SR.GetResourceString(SR.ID4008)); - - // If a request_id parameter can be found in the end session request, - // restore the complete end session request from the distributed cache. - - if (string.IsNullOrEmpty(context.Request.RequestId)) - { - return; - } - - // Note: the cache key is always prefixed with a specific marker - // to avoid collisions with the other types of cached payloads. - var token = await _cache.GetStringAsync(Cache.EndSessionRequest + context.Request.RequestId); - if (token is null || !context.Options.JsonWebTokenHandler.CanReadToken(token)) - { - context.Logger.LogInformation(SR.GetResourceString(SR.ID6150), Parameters.RequestId); - - context.Reject( - error: Errors.InvalidRequest, - description: SR.FormatID2052(Parameters.RequestId), - uri: SR.FormatID8000(SR.ID2052)); - - return; - } - - var parameters = context.Options.TokenValidationParameters.Clone(); - parameters.ValidIssuer ??= (context.Options.Issuer ?? context.BaseUri)?.AbsoluteUri; - parameters.ValidAudience ??= parameters.ValidIssuer; - parameters.ValidTypes = [JsonWebTokenTypes.Private.EndSessionRequest]; - - var result = await context.Options.JsonWebTokenHandler.ValidateTokenAsync(token, parameters); - if (!result.IsValid) - { - context.Logger.LogInformation(SR.GetResourceString(SR.ID6150), Parameters.RequestId); - - context.Reject( - error: Errors.InvalidRequest, - description: SR.FormatID2052(Parameters.RequestId), - uri: SR.FormatID8000(SR.ID2052)); - - return; - } - - using var document = JsonDocument.Parse( - Base64UrlEncoder.Decode(((JsonWebToken) result.SecurityToken).InnerToken.EncodedPayload)); - if (document.RootElement.ValueKind is not JsonValueKind.Object) - { - throw new InvalidOperationException(SR.GetResourceString(SR.ID0118)); - } - - // Restore the request parameters from the serialized payload. - foreach (var parameter in document.RootElement.EnumerateObject()) - { - if (!context.Request.HasParameter(parameter.Name)) - { - context.Request.AddParameter(parameter.Name, parameter.Value.Clone()); - } - } - } + public ValueTask HandleAsync(ExtractEndSessionRequestContext context) + => throw new NotSupportedException(SR.GetResourceString(SR.ID0403)); } /// /// Contains the logic responsible for caching end session requests, if applicable. /// Note: this handler is not used when the OpenID Connect request is not initially handled by OWIN. /// + [Obsolete("This event handler is obsolete and will be removed in a future version.")] public sealed class CacheRequestParameters : IOpenIddictServerHandler { - private readonly IDistributedCache _cache; - private readonly IOptionsMonitor _options; - - public CacheRequestParameters() => throw new InvalidOperationException(SR.GetResourceString(SR.ID0116)); + public CacheRequestParameters() => throw new NotSupportedException(SR.GetResourceString(SR.ID0403)); public CacheRequestParameters( IDistributedCache cache, IOptionsMonitor options) - { - _cache = cache ?? throw new ArgumentNullException(nameof(cache)); - _options = options ?? throw new ArgumentNullException(nameof(options)); - } + => throw new NotSupportedException(SR.GetResourceString(SR.ID0403)); /// /// Gets the default descriptor definition assigned to this handler. @@ -178,97 +98,21 @@ public static partial class OpenIddictServerOwinHandlers .Build(); /// - public async ValueTask HandleAsync(ExtractEndSessionRequestContext context) - { - if (context is null) - { - throw new ArgumentNullException(nameof(context)); - } - - if (context is not { BaseUri.IsAbsoluteUri: true, RequestUri.IsAbsoluteUri: true }) - { - throw new InvalidOperationException(SR.GetResourceString(SR.ID0127)); - } - - Debug.Assert(context.Request is not null, SR.GetResourceString(SR.ID4008)); - - // This handler only applies to OWIN requests. If The OWIN request cannot be resolved, - // this may indicate that the request was incorrectly processed by another server stack. - var request = context.Transaction.GetOwinRequest() ?? - throw new InvalidOperationException(SR.GetResourceString(SR.ID0120)); - - // Don't cache the request if the request doesn't include any parameter. - // If a request_id parameter can be found in the end session request, - // ignore the following logic to prevent an infinite redirect loop. - if (context.Request.Count is 0 || !string.IsNullOrEmpty(context.Request.RequestId)) - { - return; - } - - // Generate a 256-bit request identifier using a crypto-secure random number generator. - context.Request.RequestId = Base64UrlEncoder.Encode(OpenIddictHelpers.CreateRandomArray(size: 256)); - - // Build a list of claims matching the parameters extracted from the request. - // - // Note: in most cases, parameters should be representated as strings as requests are - // typically resolved from the query string or the request form, where parameters - // are natively represented as strings. However, requests can also be extracted from - // different places where they can be represented as complex JSON representations - // (e.g requests extracted from a JSON Web Token that may be encrypted and/or signed). - var claims = from parameter in context.Request.GetParameters() - let element = (JsonElement) parameter.Value - let type = element.ValueKind switch - { - JsonValueKind.String => ClaimValueTypes.String, - JsonValueKind.Number => ClaimValueTypes.Integer64, - JsonValueKind.True or JsonValueKind.False => ClaimValueTypes.Boolean, - JsonValueKind.Null or JsonValueKind.Undefined => JsonClaimValueTypes.JsonNull, - JsonValueKind.Array => JsonClaimValueTypes.JsonArray, - JsonValueKind.Object or _ => JsonClaimValueTypes.Json - } - select new Claim(parameter.Key, element.ToString()!, type); - - // Store the serialized end session request parameters in the distributed cache. - var token = context.Options.JsonWebTokenHandler.CreateToken(new SecurityTokenDescriptor - { - Audience = (context.Options.Issuer ?? context.BaseUri)?.AbsoluteUri, - EncryptingCredentials = context.Options.EncryptionCredentials.First(), - Issuer = (context.Options.Issuer ?? context.BaseUri)?.AbsoluteUri, - SigningCredentials = context.Options.SigningCredentials.First(), - Subject = new ClaimsIdentity(claims, TokenValidationParameters.DefaultAuthenticationType), - TokenType = JsonWebTokenTypes.Private.EndSessionRequest - }); - - // Note: the cache key is always prefixed with a specific marker - // to avoid collisions with the other types of cached payloads. - await _cache.SetStringAsync(Cache.EndSessionRequest + context.Request.RequestId, - token, _options.CurrentValue.EndSessionRequestCachingPolicy); - - // Create a new GET end session request containing only the request_id parameter. - var location = WebUtilities.AddQueryString( - uri: new UriBuilder(context.RequestUri) { Query = null }.Uri.AbsoluteUri, - name: Parameters.RequestId, - value: context.Request.RequestId); - - request.Context.Response.Redirect(location); - - // Mark the response as handled to skip the rest of the pipeline. - context.HandleRequest(); - } + public ValueTask HandleAsync(ExtractEndSessionRequestContext context) + => throw new NotSupportedException(SR.GetResourceString(SR.ID0403)); } /// /// Contains the logic responsible for removing cached end session requests from the distributed cache. /// Note: this handler is not used when the OpenID Connect request is not initially handled by OWIN. /// + [Obsolete("This event handler is obsolete and will be removed in a future version.")] public sealed class RemoveCachedRequest : IOpenIddictServerHandler { - private readonly IDistributedCache _cache; - - public RemoveCachedRequest() => throw new InvalidOperationException(SR.GetResourceString(SR.ID0116)); + public RemoveCachedRequest() => throw new NotSupportedException(SR.GetResourceString(SR.ID0403)); public RemoveCachedRequest(IDistributedCache cache) - => _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + => throw new NotSupportedException(SR.GetResourceString(SR.ID0403)); /// /// Gets the default descriptor definition assigned to this handler. @@ -282,6 +126,28 @@ public static partial class OpenIddictServerOwinHandlers .SetType(OpenIddictServerHandlerType.BuiltIn) .Build(); + /// + public ValueTask HandleAsync(ApplyEndSessionResponseContext context) + => throw new NotSupportedException(SR.GetResourceString(SR.ID0403)); + } + + /// + /// Contains the logic responsible for processing end session responses requiring a self-redirection. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by OWIN. + /// + public sealed class ProcessSelfRedirection : IOpenIddictServerHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(250_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + /// public ValueTask HandleAsync(ApplyEndSessionResponseContext context) { @@ -290,18 +156,36 @@ public static partial class OpenIddictServerOwinHandlers throw new ArgumentNullException(nameof(context)); } - if (string.IsNullOrEmpty(context.Request?.RequestId)) + if (context is not { BaseUri.IsAbsoluteUri: true, RequestUri.IsAbsoluteUri: true }) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0127)); + } + + if (string.IsNullOrEmpty(context.Response.RequestUri)) { return default; } - // Note: the ApplyEndSessionResponse event is called for both successful - // and errored end session responses but discrimination is not necessary here, - // as the end session request must be removed from the distributed cache in both cases. + // 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. + var response = context.Transaction.GetOwinRequest()?.Context.Response ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0120)); + + var location = context.RequestUri.GetLeftPart(UriPartial.Path); + foreach (var (key, value) in + from parameter in context.Response.GetParameters() + let values = (string?[]?) parameter.Value + where values is not null + from value in values + where !string.IsNullOrEmpty(value) + select (parameter.Key, Value: value)) + { + location = WebUtilities.AddQueryString(location, key, value); + } - // Note: the cache key is always prefixed with a specific marker - // to avoid collisions with the other types of cached payloads. - return new(_cache.RemoveAsync(Cache.EndSessionRequest + context.Request.RequestId)); + response.Redirect(location); + context.HandleRequest(); + return default; } } @@ -318,7 +202,7 @@ public static partial class OpenIddictServerOwinHandlers = OpenIddictServerHandlerDescriptor.CreateBuilder() .AddFilter() .UseSingletonHandler() - .SetOrder(250_000) + .SetOrder(ProcessSelfRedirection.Descriptor.Order + 1_000) .SetType(OpenIddictServerHandlerType.BuiltIn) .Build(); diff --git a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.cs b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.cs index 28fe3bda..ca80f331 100644 --- a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.cs +++ b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.cs @@ -936,8 +936,27 @@ public static partial class OpenIddictServerOwinHandlers throw new ArgumentNullException(nameof(context)); } - context.SkipRequest(); + switch (context.EndpointType) + { + // When authorization request caching is enabled and the request doesn't contain a + // request_uri yet, do not enable the pass-through mode to allow OpenIddict to trigger + // a sign-in operation that will generate and attach a request token to the parameters. + case OpenIddictServerEndpointType.Authorization when + context.Options.EnableAuthorizationRequestCaching && + string.IsNullOrEmpty(context.Transaction.Request?.RequestUri): + return default; + + // When end session request caching is enabled and the request doesn't contain a + // request_uri yet, do not enable the pass-through mode to allow OpenIddict to trigger + // a sign-in operation that will generate and attach a request token to the parameters. + case OpenIddictServerEndpointType.EndSession when + context.Options.EnableEndSessionRequestCaching && + string.IsNullOrEmpty(context.Transaction.Request?.RequestUri): + return default; + } + + context.SkipRequest(); return default; } } @@ -976,6 +995,10 @@ public static partial class OpenIddictServerOwinHandlers response.StatusCode = (context.EndpointType, context.Transaction.Response.Error) switch { + // Note: for pushed authorization responses, the returned HTTP status code MUST be 201. + // See https://datatracker.ietf.org/doc/html/rfc9126#section-2.2 for more information. + (OpenIddictServerEndpointType.PushedAuthorization, null or { Length: 0 }) => 201, + // Note: the default code may be replaced by another handler (e.g when doing redirects). (_, null or { Length: 0 }) => 200, diff --git a/src/OpenIddict.Server.Owin/OpenIddictServerOwinOptions.cs b/src/OpenIddict.Server.Owin/OpenIddictServerOwinOptions.cs index 24d33ee6..fd41e080 100644 --- a/src/OpenIddict.Server.Owin/OpenIddictServerOwinOptions.cs +++ b/src/OpenIddict.Server.Owin/OpenIddictServerOwinOptions.cs @@ -82,12 +82,14 @@ public sealed class OpenIddictServerOwinOptions : AuthenticationOptions /// Enabling this option is recommended when using external authentication providers /// or when large GET or POST OpenID Connect authorization requests support is required. /// + [Obsolete("This property is obsolete and will be removed in a future version.")] public bool EnableAuthorizationRequestCaching { get; set; } /// /// Gets or sets a boolean indicating whether requests received by the end session endpoint should be cached. /// When enabled, authorization requests are automatically stored in the distributed cache. /// + [Obsolete("This property is obsolete and will be removed in a future version.")] public bool EnableEndSessionRequestCaching { get; set; } /// @@ -103,6 +105,7 @@ public sealed class OpenIddictServerOwinOptions : AuthenticationOptions /// /// Gets or sets the caching policy used by the authorization endpoint. /// + [Obsolete("This property is obsolete and will be removed in a future version.")] public DistributedCacheEntryOptions AuthorizationRequestCachingPolicy { get; set; } = new() { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1), @@ -112,6 +115,7 @@ public sealed class OpenIddictServerOwinOptions : AuthenticationOptions /// /// Gets or sets the caching policy used by the end session endpoint. /// + [Obsolete("This property is obsolete and will be removed in a future version.")] public DistributedCacheEntryOptions EndSessionRequestCachingPolicy { get; set; } = new() { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1), diff --git a/src/OpenIddict.Server/OpenIddictServerBuilder.cs b/src/OpenIddict.Server/OpenIddictServerBuilder.cs index 85b1ca2d..94c1430a 100644 --- a/src/OpenIddict.Server/OpenIddictServerBuilder.cs +++ b/src/OpenIddict.Server/OpenIddictServerBuilder.cs @@ -1385,6 +1385,55 @@ public sealed class OpenIddictServerBuilder }); } + /// + /// Sets the relative or absolute URIs associated to the pushed authorization endpoint. + /// If an empty array is specified, the endpoint will be considered disabled. + /// Note: only the first URI will be returned as part of the discovery document. + /// + /// The URIs associated to the endpoint. + /// The instance. + public OpenIddictServerBuilder SetPushedAuthorizationEndpointUris( + [StringSyntax(StringSyntaxAttribute.Uri)] params string[] uris) + { + if (uris is null) + { + throw new ArgumentNullException(nameof(uris)); + } + + return SetPushedAuthorizationEndpointUris(uris.Select(uri => new Uri(uri, UriKind.RelativeOrAbsolute)).ToArray()); + } + + /// + /// Sets the relative or absolute URIs associated to the pushed authorization endpoint. + /// If an empty array is specified, the endpoint will be considered disabled. + /// Note: only the first URI will be returned as part of the discovery document. + /// + /// The URIs associated to the endpoint. + /// The instance. + public OpenIddictServerBuilder SetPushedAuthorizationEndpointUris(params Uri[] uris) + { + if (uris is null) + { + throw new ArgumentNullException(nameof(uris)); + } + + if (Array.Exists(uris, OpenIddictHelpers.IsImplicitFileUri)) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0072), nameof(uris)); + } + + if (Array.Exists(uris, static uri => uri.OriginalString.StartsWith("~", StringComparison.OrdinalIgnoreCase))) + { + throw new ArgumentException(SR.FormatID0081("~"), nameof(uris)); + } + + return Configure(options => + { + options.PushedAuthorizationEndpointUris.Clear(); + options.PushedAuthorizationEndpointUris.AddRange(uris); + }); + } + /// /// Sets the relative or absolute URIs associated to the revocation endpoint. /// If an empty array is specified, the endpoint will be considered disabled. @@ -1745,12 +1794,21 @@ public sealed class OpenIddictServerBuilder /// /// Configures OpenIddict to force client applications to use Proof Key for Code Exchange /// (PKCE) when requesting an authorization code (e.g when using the code or hybrid flows). - /// When enforced, authorization requests that lack the code_challenge will be rejected. + /// When enforced, authorization requests that lack the code_challenge parameter will be rejected. /// /// The instance. public OpenIddictServerBuilder RequireProofKeyForCodeExchange() => Configure(options => options.RequireProofKeyForCodeExchange = true); + /// + /// Configures OpenIddict to force client applications to use pushed authorization requests + /// when using an interactive flow like the authorization code or implicit flows. + /// When enforced, authorization requests that lack the request_id parameter will be rejected. + /// + /// The instance. + public OpenIddictServerBuilder RequirePushedAuthorizationRequests() + => Configure(options => options.RequirePushedAuthorizationRequests = true); + /// /// Sets the access token lifetime, after which client applications must retrieve /// a new access token by making a grant_type=refresh_token token request @@ -1968,6 +2026,25 @@ public sealed class OpenIddictServerBuilder public OpenIddictServerBuilder UseReferenceRefreshTokens() => Configure(options => options.UseReferenceRefreshTokens = true); + /// + /// Enables authorization request storage, so that authorization requests + /// are automatically stored in the token store, which allows flowing + /// large payloads across requests. Enabling this option can be useful + /// for clients that do not supported pushed authorization requests. + /// + /// The instance. + public OpenIddictServerBuilder EnableAuthorizationRequestCaching() + => Configure(options => options.EnableAuthorizationRequestCaching = true); + + /// + /// Enables end session request storage, so that end session requests + /// are automatically stored in the token store, which allows flowing + /// large payloads across requests. + /// + /// The instance. + public OpenIddictServerBuilder EnableEndSessionRequestCaching() + => Configure(options => options.EnableEndSessionRequestCaching = true); + /// [EditorBrowsable(EditorBrowsableState.Never)] public override bool Equals(object? obj) => base.Equals(obj); diff --git a/src/OpenIddict.Server/OpenIddictServerConfiguration.cs b/src/OpenIddict.Server/OpenIddictServerConfiguration.cs index b8caa78d..de9c9300 100644 --- a/src/OpenIddict.Server/OpenIddictServerConfiguration.cs +++ b/src/OpenIddict.Server/OpenIddictServerConfiguration.cs @@ -7,7 +7,6 @@ using System.ComponentModel; using System.Diagnostics; using System.Globalization; -using System.Runtime.InteropServices; using System.Text; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -47,6 +46,7 @@ public sealed class OpenIddictServerConfiguration : IPostConfigureOptions + (descriptor.ContextType == typeof(ValidatePushedAuthorizationRequestContext) || + descriptor.ContextType == typeof(ProcessAuthenticationContext)) && + descriptor.Type == OpenIddictServerHandlerType.Custom && + descriptor.FilterTypes.All(type => !typeof(RequireDegradedModeDisabled).IsAssignableFrom(type)))) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0467)); + } + if (options.RevocationEndpointUris.Count is not 0 && !options.Handlers.Exists(static descriptor => (descriptor.ContextType == typeof(ValidateRevocationRequestContext) || descriptor.ContextType == typeof(ProcessAuthenticationContext)) && diff --git a/src/OpenIddict.Server/OpenIddictServerEndpointType.cs b/src/OpenIddict.Server/OpenIddictServerEndpointType.cs index e17a4ead..7104c171 100644 --- a/src/OpenIddict.Server/OpenIddictServerEndpointType.cs +++ b/src/OpenIddict.Server/OpenIddictServerEndpointType.cs @@ -64,5 +64,10 @@ public enum OpenIddictServerEndpointType /// /// User verification endpoint. /// - EndUserVerification = 10 + EndUserVerification = 10, + + /// + /// Pushed authorization endpoint. + /// + PushedAuthorization = 11 } diff --git a/src/OpenIddict.Server/OpenIddictServerEvents.Authentication.cs b/src/OpenIddict.Server/OpenIddictServerEvents.Authentication.cs index 72a2e966..62cc115a 100644 --- a/src/OpenIddict.Server/OpenIddictServerEvents.Authentication.cs +++ b/src/OpenIddict.Server/OpenIddictServerEvents.Authentication.cs @@ -69,7 +69,7 @@ public static partial class OpenIddictServerEvents /// the user code by calling . /// [StringSyntax(StringSyntaxAttribute.Uri)] - public string? RedirectUri { get; private set; } + public string? RedirectUri { get; internal set; } /// /// Gets or sets the security principal extracted @@ -77,6 +77,12 @@ public static partial class OpenIddictServerEvents /// public ClaimsPrincipal? IdentityTokenHintPrincipal { get; set; } + /// + /// Gets or sets the security principal extracted from the + /// request token, if applicable. + /// + public ClaimsPrincipal? RequestTokenPrincipal { get; set; } + /// /// Populates the property with the specified redirect_uri. /// @@ -219,4 +225,200 @@ public static partial class OpenIddictServerEvents /// public string? ResponseMode { get; set; } } + + + /// + /// Represents an event called for each request to the pushed authorization endpoint to give the + /// user code a chance to manually extract the authorization request from the ambient HTTP context. + /// + public sealed class ExtractPushedAuthorizationRequestContext : BaseValidatingContext + { + /// + /// Creates a new instance of the class. + /// + public ExtractPushedAuthorizationRequestContext(OpenIddictServerTransaction transaction) + : base(transaction) + { + } + + /// + /// Gets or sets the request or if it was extracted yet. + /// + public OpenIddictRequest? Request + { + get => Transaction.Request; + set => Transaction.Request = value; + } + } + + /// + /// Represents an event called for each request to the pushed authorization request + /// endpoint to determine if the request is valid and should continue to be processed. + /// + public sealed class ValidatePushedAuthorizationRequestContext : BaseValidatingContext + { + /// + /// Creates a new instance of the class. + /// + public ValidatePushedAuthorizationRequestContext(OpenIddictServerTransaction transaction) + : base(transaction) + // Infer the redirect_uri from the value specified by the client application. + => RedirectUri = Request?.RedirectUri; + + /// + /// Gets or sets the request. + /// + public OpenIddictRequest Request + { + get => Transaction.Request!; + set => Transaction.Request = value; + } + + /// + /// Gets the client_id specified by the client application. + /// + public string? ClientId => Request?.ClientId; + + /// + /// Gets the redirect_uri specified by the client application. + /// If it's not provided by the client, it must be set by + /// the user code by calling . + /// + [StringSyntax(StringSyntaxAttribute.Uri)] + public string? RedirectUri { get; private set; } + + /// + /// Gets or sets the security principal extracted + /// from the identity token hint, if applicable. + /// + public ClaimsPrincipal? IdentityTokenHintPrincipal { get; set; } + + /// + /// Populates the property with the specified redirect_uri. + /// + /// The redirect_uri to use when redirecting the user agent. + public void SetRedirectUri([StringSyntax(StringSyntaxAttribute.Uri)] string uri) + { + if (string.IsNullOrEmpty(uri)) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0100), nameof(uri)); + } + + // Don't allow validation to alter the redirect_uri parameter extracted + // from the request if the URI was explicitly provided by the client. + if (!string.IsNullOrEmpty(Request?.RedirectUri) && + !string.Equals(Request.RedirectUri, uri, StringComparison.Ordinal)) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0101)); + } + + RedirectUri = uri; + } + } + + /// + /// Represents an event called for each validated pushed authorization request + /// to allow the user code to decide how the request should be handled. + /// + public sealed class HandlePushedAuthorizationRequestContext : BaseValidatingTicketContext + { + /// + /// Creates a new instance of the class. + /// + public HandlePushedAuthorizationRequestContext(OpenIddictServerTransaction transaction) + : base(transaction) + { + } + + /// + /// Gets or sets the request. + /// + public OpenIddictRequest Request + { + get => Transaction.Request!; + set => Transaction.Request = value; + } + + /// + /// Gets or sets the security principal extracted + /// from the identity token hint, if applicable. + /// + public ClaimsPrincipal? IdentityTokenHintPrincipal { get; set; } + + /// + /// Gets the additional parameters returned to the client application. + /// + public Dictionary Parameters { get; private set; } + = new(StringComparer.Ordinal); + + /// + /// Allows OpenIddict to return a sign-in response using the specified principal. + /// + /// The claims principal. + public void SignIn(ClaimsPrincipal principal) => Principal = principal; + + /// + /// Allows OpenIddict to return a sign-in response using the specified principal. + /// + /// The claims principal. + /// The additional parameters returned to the client application. + public void SignIn(ClaimsPrincipal principal, IDictionary parameters) + { + Principal = principal; + Parameters = new(parameters, StringComparer.Ordinal); + } + } + + /// + /// Represents an event called before the pushed authorization response is returned to the caller. + /// + public sealed class ApplyPushedAuthorizationResponseContext : BaseRequestContext + { + /// + /// Creates a new instance of the class. + /// + public ApplyPushedAuthorizationResponseContext(OpenIddictServerTransaction transaction) + : base(transaction) + { + } + + /// + /// Gets or sets the request, or if it couldn't be extracted. + /// + 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 the access code expected to + /// be returned to the client application. + /// Depending on the flow, it may be null. + /// + public string? AccessToken => Response?.AccessToken; + + /// + /// Gets the authorization code expected to + /// be returned to the client application. + /// Depending on the flow, it may be null. + /// + public string? AuthorizationCode => Response?.Code; + + /// + /// Gets the error code returned to the client application. + /// When the response indicates a successful response, + /// this property returns . + /// + public string? Error => Response?.Error; + } } diff --git a/src/OpenIddict.Server/OpenIddictServerEvents.Discovery.cs b/src/OpenIddict.Server/OpenIddictServerEvents.Discovery.cs index 7370a7c5..50ac2b82 100644 --- a/src/OpenIddict.Server/OpenIddictServerEvents.Discovery.cs +++ b/src/OpenIddict.Server/OpenIddictServerEvents.Discovery.cs @@ -116,6 +116,11 @@ public static partial class OpenIddictServerEvents /// public Uri? IntrospectionEndpoint { get; set; } + /// + /// Gets or sets the pushed authorization endpoint URI. + /// + public Uri? PushedAuthorizationEndpoint { get; set; } + /// /// Gets or sets the revocation endpoint URI. /// @@ -171,6 +176,12 @@ public static partial class OpenIddictServerEvents /// public HashSet PromptValues { get; } = new(StringComparer.Ordinal); + /// + /// Gets a list of client authentication methods supported by the pushed + /// authorization endpoint provided by the authorization server. + /// + public HashSet PushedAuthorizationEndpointAuthenticationMethods { get; } = new(StringComparer.Ordinal); + /// /// Gets the list of response modes /// supported by the authorization server. @@ -206,6 +217,11 @@ public static partial class OpenIddictServerEvents /// the token endpoint provided by the authorization server. /// public HashSet TokenEndpointAuthenticationMethods { get; } = new(StringComparer.Ordinal); + + /// + /// Gets or sets a boolean indicating whether pushed authorization requests are required. + /// + public bool RequirePushedAuthorizationRequests { get; set; } } /// diff --git a/src/OpenIddict.Server/OpenIddictServerEvents.Session.cs b/src/OpenIddict.Server/OpenIddictServerEvents.Session.cs index 9980bb0b..83ba0512 100644 --- a/src/OpenIddict.Server/OpenIddictServerEvents.Session.cs +++ b/src/OpenIddict.Server/OpenIddictServerEvents.Session.cs @@ -67,7 +67,7 @@ public static partial class OpenIddictServerEvents /// Gets the post_logout_redirect_uri specified by the client application. /// [StringSyntax(StringSyntaxAttribute.Uri)] - public string? PostLogoutRedirectUri { get; private set; } + public string? PostLogoutRedirectUri { get; internal set; } /// /// Gets or sets the security principal extracted @@ -75,6 +75,12 @@ public static partial class OpenIddictServerEvents /// public ClaimsPrincipal? IdentityTokenHintPrincipal { get; set; } + /// + /// Gets or sets the security principal extracted from the + /// request token, if applicable. + /// + public ClaimsPrincipal? RequestTokenPrincipal { get; set; } + /// /// Populates the property with the specified redirect_uri. /// diff --git a/src/OpenIddict.Server/OpenIddictServerEvents.cs b/src/OpenIddict.Server/OpenIddictServerEvents.cs index 391e1b03..a3733b6c 100644 --- a/src/OpenIddict.Server/OpenIddictServerEvents.cs +++ b/src/OpenIddict.Server/OpenIddictServerEvents.cs @@ -381,6 +381,15 @@ public static partial class OpenIddictServerEvents /// public bool ExtractRefreshToken { get; set; } + /// + /// Gets or sets a boolean indicating whether a request token + /// should be extracted from the current context. + /// + /// + /// Note: overriding the value of this property is generally not recommended. + /// + public bool ExtractRequestToken { get; set; } + /// /// Gets or sets a boolean indicating whether a user /// code should be extracted from the current context. @@ -453,6 +462,15 @@ public static partial class OpenIddictServerEvents /// public bool RequireRefreshToken { get; set; } + /// + /// Gets or sets a boolean indicating whether a request token + /// must be resolved for the authentication to be considered valid. + /// + /// + /// Note: overriding the value of this property is generally not recommended. + /// + public bool RequireRequestToken { get; set; } + /// /// Gets or sets a boolean indicating whether a user code /// must be resolved for the authentication to be considered valid. @@ -525,6 +543,15 @@ public static partial class OpenIddictServerEvents /// public bool ValidateRefreshToken { get; set; } + /// + /// Gets or sets a boolean indicating whether the request token + /// extracted from the current request should be validated. + /// + /// + /// Note: overriding the value of this property is generally not recommended. + /// + public bool ValidateRequestToken { get; set; } + /// /// Gets or sets a boolean indicating whether the user /// code extracted from the current request should be validated. @@ -597,6 +624,15 @@ public static partial class OpenIddictServerEvents /// public bool RejectRefreshToken { get; set; } + /// + /// Gets or sets a boolean indicating whether an invalid request token + /// will cause the authentication demand to be rejected or will be ignored. + /// + /// + /// Note: overriding the value of this property is generally not recommended. + /// + public bool RejectRequestToken { get; set; } + /// /// Gets or sets a boolean indicating whether an invalid user code /// will cause the authentication demand to be rejected or will be ignored. @@ -686,6 +722,16 @@ public static partial class OpenIddictServerEvents /// public ClaimsPrincipal? RefreshTokenPrincipal { get; set; } + /// + /// Gets or sets the request token to validate, if applicable. + /// + public string? RequestToken { get; set; } + + /// + /// Gets or sets the principal extracted from the request token, if applicable. + /// + public ClaimsPrincipal? RequestTokenPrincipal { get; set; } + /// /// Gets or sets the user code to validate, if applicable. /// @@ -825,6 +871,15 @@ public static partial class OpenIddictServerEvents /// public bool GenerateRefreshToken { get; set; } + /// + /// Gets or sets a boolean indicating whether a request token + /// should be generated (and optionally returned to the client). + /// + /// + /// Note: overriding the value of this property is generally not recommended. + /// + public bool GenerateRequestToken { get; set; } + /// /// Gets or sets a boolean indicating whether a user code /// should be generated (and optionally returned to the client). @@ -879,6 +934,15 @@ public static partial class OpenIddictServerEvents /// public bool IncludeRefreshToken { get; set; } + /// + /// Gets or sets a boolean indicating whether the generated request token + /// should be returned to the client application as part of the response. + /// + /// + /// Note: overriding the value of this property is generally not recommended. + /// + public bool IncludeRequestToken { get; set; } + /// /// Gets or sets a boolean indicating whether the generated user code /// should be returned to the client application as part of the response. @@ -953,6 +1017,19 @@ public static partial class OpenIddictServerEvents /// public ClaimsPrincipal? RefreshTokenPrincipal { get; set; } + /// + /// Gets or sets the generated request token, if applicable. + /// The request token will only be returned if + /// is set to . + /// + public string? RequestToken { get; set; } + + /// + /// Gets or sets the principal containing the claims that will be used + /// to create the request token, if applicable. + /// + public ClaimsPrincipal? RequestTokenPrincipal { get; set; } + /// /// Gets or sets the generated user code, if applicable. /// The user code will only be returned if diff --git a/src/OpenIddict.Server/OpenIddictServerExtensions.cs b/src/OpenIddict.Server/OpenIddictServerExtensions.cs index 476a71d6..1e1c10c1 100644 --- a/src/OpenIddict.Server/OpenIddictServerExtensions.cs +++ b/src/OpenIddict.Server/OpenIddictServerExtensions.cs @@ -67,10 +67,14 @@ public static class OpenIddictServerExtensions 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(); diff --git a/src/OpenIddict.Server/OpenIddictServerHandlerFilters.cs b/src/OpenIddict.Server/OpenIddictServerHandlerFilters.cs index a73d1971..392a689d 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlerFilters.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlerFilters.cs @@ -470,6 +470,23 @@ public static class OpenIddictServerHandlerFilters } } + /// + /// Represents a filter that excludes the associated handlers if the request is not a pushed authorization request. + /// + public sealed class RequirePushedAuthorizationRequest : IOpenIddictServerHandlerFilter + { + /// + public ValueTask IsActiveAsync(BaseContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + return new(context.EndpointType is OpenIddictServerEndpointType.PushedAuthorization); + } + } + /// /// Represents a filter that excludes the associated handlers if reference access tokens are disabled. /// @@ -504,6 +521,57 @@ public static class OpenIddictServerHandlerFilters } } + /// + /// Represents a filter that excludes the associated handlers if no request token is generated. + /// + public sealed class RequireRequestTokenGenerated : IOpenIddictServerHandlerFilter + { + /// + public ValueTask IsActiveAsync(ProcessSignInContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + return new(context.GenerateRequestToken); + } + } + + /// + /// Represents a filter that excludes the associated handlers if no request token principal is available. + /// + public sealed class RequireRequestTokenPrincipal : IOpenIddictServerHandlerFilter + { + /// + public ValueTask IsActiveAsync(ProcessAuthenticationContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + return new(context.RequestTokenPrincipal is not null); + } + } + + /// + /// Represents a filter that excludes the associated handlers if no request token is validated. + /// + public sealed class RequireRequestTokenValidated : IOpenIddictServerHandlerFilter + { + /// + public ValueTask IsActiveAsync(ProcessAuthenticationContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + return new(context.ValidateRequestToken); + } + } + /// /// Represents a filter that excludes the associated handlers if no refresh token is generated. /// diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs index 2dd60f63..790c109c 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs @@ -6,6 +6,9 @@ using System.Collections.Immutable; using System.Diagnostics; +using System.Globalization; +using System.Security.Claims; +using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -35,6 +38,8 @@ public static partial class OpenIddictServerHandlers ValidateRequestParameter.Descriptor, ValidateRequestUriParameter.Descriptor, ValidateClientIdParameter.Descriptor, + ValidateAuthentication.Descriptor, + RestorePushedAuthorizationRequestParameters.Descriptor, ValidateRedirectUriParameter.Descriptor, ValidateResponseTypeParameter.Descriptor, ValidateResponseModeParameter.Descriptor, @@ -42,7 +47,6 @@ public static partial class OpenIddictServerHandlers ValidateNonceParameter.Descriptor, ValidatePromptParameter.Descriptor, ValidateProofKeyForCodeExchangeParameters.Descriptor, - ValidateAuthentication.Descriptor, ValidateResponseType.Descriptor, ValidateClientRedirectUri.Descriptor, ValidateScopes.Descriptor, @@ -50,6 +54,7 @@ public static partial class OpenIddictServerHandlers ValidateGrantTypePermissions.Descriptor, ValidateResponseTypePermissions.Descriptor, ValidateScopePermissions.Descriptor, + ValidatePushedAuthorizationRequestsRequirement.Descriptor, ValidateProofKeyForCodeExchangeRequirement.Descriptor, ValidateAuthorizedParty.Descriptor, @@ -64,7 +69,47 @@ public static partial class OpenIddictServerHandlers AttachRedirectUri.Descriptor, InferResponseMode.Descriptor, AttachResponseState.Descriptor, - AttachIssuer.Descriptor + AttachIssuer.Descriptor, + + /* + * Pushed authorization request top-level processing: + */ + ExtractPushedAuthorizationRequest.Descriptor, + ValidatePushedAuthorizationRequest.Descriptor, + HandlePushedAuthorizationRequest.Descriptor, + ApplyPushedAuthorizationResponse.Descriptor, + ApplyPushedAuthorizationResponse.Descriptor, + ApplyPushedAuthorizationResponse.Descriptor, + ApplyPushedAuthorizationResponse.Descriptor, + + /* + * Pushed authorization request validation: + */ + ValidatePushedRequestParameter.Descriptor, + ValidatePushedRequestUriParameter.Descriptor, + ValidatePushedClientIdParameter.Descriptor, + ValidatePushedRedirectUriParameter.Descriptor, + ValidatePushedResponseTypeParameter.Descriptor, + ValidatePushedResponseModeParameter.Descriptor, + ValidatePushedScopeParameter.Descriptor, + ValidatePushedNonceParameter.Descriptor, + ValidatePushedPromptParameter.Descriptor, + ValidatePushedProofKeyForCodeExchangeParameters.Descriptor, + ValidatePushedAuthentication.Descriptor, + ValidatePushedResponseType.Descriptor, + ValidatePushedClientRedirectUri.Descriptor, + ValidatePushedScopes.Descriptor, + ValidatePushedEndpointPermissions.Descriptor, + ValidatePushedGrantTypePermissions.Descriptor, + ValidatePushedResponseTypePermissions.Descriptor, + ValidatePushedScopePermissions.Descriptor, + ValidatePushedProofKeyForCodeExchangeRequirement.Descriptor, + ValidatePushedAuthorizedParty.Descriptor, + + /* + * Pushed authorization request handling: + */ + AttachPushedPrincipal.Descriptor ]); /// @@ -288,6 +333,47 @@ public static partial class OpenIddictServerHandlers } } + else if (context.Options.EnableAuthorizationRequestCaching && + string.IsNullOrEmpty(context.Transaction.Request?.RequestUri)) + { + var @event = new ProcessSignInContext(context.Transaction) + { + Principal = new ClaimsPrincipal(new ClaimsIdentity()), + Response = new OpenIddictResponse() + }; + + if (notification.Parameters.Count > 0) + { + foreach (var parameter in notification.Parameters) + { + @event.Parameters.Add(parameter.Key, parameter.Value); + } + } + + await _dispatcher.DispatchAsync(@event); + + if (@event.IsRequestHandled) + { + context.HandleRequest(); + return; + } + + else if (@event.IsRequestSkipped) + { + context.SkipRequest(); + return; + } + + else if (@event.IsRejected) + { + context.Reject( + error: notification.Error ?? Errors.InvalidRequest, + description: notification.ErrorDescription, + uri: notification.ErrorUri); + return; + } + } + throw new InvalidOperationException(SR.GetResourceString(SR.ID0029)); } } @@ -381,7 +467,7 @@ public static partial class OpenIddictServerHandlers } /// - /// Contains the logic responsible for rejecting authorization requests that specify the unsupported request_uri parameter. + /// Contains the logic responsible for rejecting authorization requests that specify an invalid request_uri parameter. /// public sealed class ValidateRequestUriParameter : IOpenIddictServerHandler { @@ -403,8 +489,28 @@ public static partial class OpenIddictServerHandlers throw new ArgumentNullException(nameof(context)); } - // Reject requests using the unsupported request_uri parameter. - if (!string.IsNullOrEmpty(context.Request.RequestUri)) + if (string.IsNullOrEmpty(context.Request.RequestUri)) + { + // If OpenIddict was configured to globally require pushed authorization requests, + // eagerly reject the request if the "request_uri" parameter is missing or empty. + if (context.Options.RequirePushedAuthorizationRequests) + { + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2029(Parameters.RequestUri), + uri: SR.FormatID8000(SR.ID2029)); + + return default; + } + + return default; + } + + // OpenIddict only supports "request_uri" parameters containing a reference to a request + // token generated during a pushed authorization response or via the automatic request + // caching feature when explicitly enabled in the options. Since OpenIddict uses a specific + // URN prefix for request tokens it generates, all the other values are automatically rejected. + if (!context.Request.RequestUri.StartsWith(RequestUris.Prefixes.Generic, StringComparison.Ordinal)) { context.Logger.LogInformation(SR.GetResourceString(SR.ID6032), Parameters.RequestUri); @@ -416,6 +522,20 @@ public static partial class OpenIddictServerHandlers return default; } + // Both the OpenID Connect core and OAuth 2.0 JWT-Secured Authorization Request specifications + // require attaching the client identifier as a regular OAuth 2.0 authorization request parameter. + // + // See https://datatracker.ietf.org/doc/html/rfc9101#section-5 for more information. + if (string.IsNullOrEmpty(context.Request.ClientId)) + { + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2177(Parameters.ClientId), + uri: SR.FormatID8000(SR.ID2177)); + + return default; + } + return default; } } @@ -461,6 +581,148 @@ public static partial class OpenIddictServerHandlers } } + /// + /// Contains the logic responsible for applying the authentication logic to authorization requests. + /// + public sealed class ValidateAuthentication : IOpenIddictServerHandler + { + private readonly IOpenIddictServerDispatcher _dispatcher; + + public ValidateAuthentication(IOpenIddictServerDispatcher dispatcher) + => _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .UseScopedHandler() + .SetOrder(ValidateClientIdParameter.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public async ValueTask HandleAsync(ValidateAuthorizationRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + var notification = new ProcessAuthenticationContext(context.Transaction); + await _dispatcher.DispatchAsync(notification); + + // Store the context object in the transaction so it can be later retrieved by handlers + // that want to access the authentication result without triggering a new authentication flow. + context.Transaction.SetProperty(typeof(ProcessAuthenticationContext).FullName!, 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; + } + + // Attach the security principals extracted from the tokens to the validation context. + context.IdentityTokenHintPrincipal = notification.IdentityTokenPrincipal; + context.RequestTokenPrincipal = notification.RequestTokenPrincipal; + } + } + + /// + /// Contains the logic responsible for restoring the parameters attached to the pushed authorization request. + /// + public sealed class RestorePushedAuthorizationRequestParameters : IOpenIddictServerHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ValidateAuthentication.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ValidateAuthorizationRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + var value = context.RequestTokenPrincipal?.GetClaim(Claims.Private.RequestParameters); + if (string.IsNullOrEmpty(value)) + { + return default; + } + + using var document = JsonDocument.Parse(value); + var request = new OpenIddictRequest(document.RootElement.Clone()) + { + RequestUri = context.Request.RequestUri + }; + + // Ensure the client_id attached to the regular authorization request + // matches the value present in the request token principal. + if (!string.Equals(request.ClientId, context.Request.ClientId, StringComparison.Ordinal)) + { + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2178(Parameters.ClientId), + uri: SR.FormatID8000(SR.ID2178)); + + return default; + } + + // Note: the "request" and "request_uri" parameters have been initially introduced by the OpenID Connect + // core specification, that allows overriding the parameters contained in the request object by attaching + // parameters to the query string (or to the request form, for POST requests) of the authorization request. + // This mechanism allows using pre-computed or static request objects while still being able to attach + // dynamic values (e.g a state value) to the authorization requests. Unfortunately, when this feature was + // backported to OAuth 2.0 by the OAuth 2.0 JWT-Secured Authorization Request specification, an incompatible + // design was defined, as authorization servers MUST now ignore parameters that are attached as regular + // OAuth 2.0 parameters to the authorization requests (i.e not attached to the request object/PAR request). + // + // Since the design defined in the OAuth 2.0 JWT-Secured Authorization Request specification is safer, it + // is the approach implemented by OpenIddict, that ignores all the parameters directly attached to the + // authorization requests when a request token (e.g retrieved using a pushed authorization request) is used. + // + // For more information, see https://datatracker.ietf.org/doc/html/rfc9101#section-5 and + // https://openid.net/specs/openid-connect-core-1_0.html#RequestUriRationale. + + // Note: the prompt parameter is special-cased to allow application code to override the "login" prompt + // value after redirecting the user agent to the login endpoint and asking the user to re-authenticate. + if (request.HasPromptValue(PromptValues.Login) && context.Request.HasParameter(Parameters.Prompt) && + !context.Request.HasPromptValue(PromptValues.Login)) + { + request.Prompt = string.Join(",", request.GetPromptValues() + .ToImmutableHashSet(StringComparer.Ordinal) + .Remove(PromptValues.Login)); + } + + context.Request = request; + context.RedirectUri = request.RedirectUri; + + return default; + } + } + /// /// Contains the logic responsible for rejecting authorization requests that lack the mandatory redirect_uri parameter. /// @@ -472,7 +734,7 @@ public static partial class OpenIddictServerHandlers public static OpenIddictServerHandlerDescriptor Descriptor { get; } = OpenIddictServerHandlerDescriptor.CreateBuilder() .UseSingletonHandler() - .SetOrder(ValidateClientIdParameter.Descriptor.Order + 1_000) + .SetOrder(RestorePushedAuthorizationRequestParameters.Descriptor.Order + 1_000) .SetType(OpenIddictServerHandlerType.BuiltIn) .Build(); @@ -1023,67 +1285,6 @@ public static partial class OpenIddictServerHandlers } } - /// - /// Contains the logic responsible for applying the authentication logic to authorization requests. - /// - public sealed class ValidateAuthentication : IOpenIddictServerHandler - { - private readonly IOpenIddictServerDispatcher _dispatcher; - - public ValidateAuthentication(IOpenIddictServerDispatcher dispatcher) - => _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); - - /// - /// Gets the default descriptor definition assigned to this handler. - /// - public static OpenIddictServerHandlerDescriptor Descriptor { get; } - = OpenIddictServerHandlerDescriptor.CreateBuilder() - .UseScopedHandler() - .SetOrder(ValidateProofKeyForCodeExchangeParameters.Descriptor.Order + 1_000) - .SetType(OpenIddictServerHandlerType.BuiltIn) - .Build(); - - /// - public async ValueTask HandleAsync(ValidateAuthorizationRequestContext context) - { - if (context is null) - { - throw new ArgumentNullException(nameof(context)); - } - - var notification = new ProcessAuthenticationContext(context.Transaction); - await _dispatcher.DispatchAsync(notification); - - // Store the context object in the transaction so it can be later retrieved by handlers - // that want to access the authentication result without triggering a new authentication flow. - context.Transaction.SetProperty(typeof(ProcessAuthenticationContext).FullName!, 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; - } - - // Attach the security principal extracted from the token to the validation context. - context.IdentityTokenHintPrincipal = notification.IdentityTokenPrincipal; - } - } - /// /// Contains the logic responsible for rejecting authorization requests that use an unsafe response type. /// @@ -1110,7 +1311,7 @@ public static partial class OpenIddictServerHandlers new ValidateResponseType(provider.GetService() ?? throw new InvalidOperationException(SR.GetResourceString(SR.ID0016))); }) - .SetOrder(ValidateAuthentication.Descriptor.Order + 1_000) + .SetOrder(ValidateProofKeyForCodeExchangeParameters.Descriptor.Order + 1_000) .SetType(OpenIddictServerHandlerType.BuiltIn) .Build(); @@ -1627,16 +1828,16 @@ public static partial class OpenIddictServerHandlers /// /// Contains the logic responsible for rejecting authorization requests made by - /// applications for which proof key for code exchange (PKCE) was enforced. + /// applications for which pushed authorization requests (PAR) are enforced. /// Note: this handler is not used when the degraded mode is enabled. /// - public sealed class ValidateProofKeyForCodeExchangeRequirement : IOpenIddictServerHandler + public sealed class ValidatePushedAuthorizationRequestsRequirement : IOpenIddictServerHandler { private readonly IOpenIddictApplicationManager _applicationManager; - public ValidateProofKeyForCodeExchangeRequirement() => throw new InvalidOperationException(SR.GetResourceString(SR.ID0016)); + public ValidatePushedAuthorizationRequestsRequirement() => throw new InvalidOperationException(SR.GetResourceString(SR.ID0016)); - public ValidateProofKeyForCodeExchangeRequirement(IOpenIddictApplicationManager applicationManager) + public ValidatePushedAuthorizationRequestsRequirement(IOpenIddictApplicationManager applicationManager) => _applicationManager = applicationManager ?? throw new ArgumentNullException(nameof(applicationManager)); /// @@ -1645,7 +1846,7 @@ public static partial class OpenIddictServerHandlers public static OpenIddictServerHandlerDescriptor Descriptor { get; } = OpenIddictServerHandlerDescriptor.CreateBuilder() .AddFilter() - .UseScopedHandler() + .UseScopedHandler() .SetOrder(ValidateScopePermissions.Descriptor.Order + 1_000) .SetType(OpenIddictServerHandlerType.BuiltIn) .Build(); @@ -1660,9 +1861,10 @@ public static partial class OpenIddictServerHandlers Debug.Assert(!string.IsNullOrEmpty(context.ClientId), SR.FormatID4000(Parameters.ClientId)); - // If a code_challenge was provided or if no authorization code is requested, the request is always - // considered valid, whether the proof key for code exchange requirement is enforced or not. - if (!string.IsNullOrEmpty(context.Request.CodeChallenge) || !context.Request.HasResponseType(ResponseTypes.Code)) + // If a request token principal with the correct type could be extracted, the request is always + // considered valid, whether the pushed authorization requests requirement is enforced or not. + var type = context.RequestTokenPrincipal?.GetClaim(Claims.Private.RequestTokenType); + if (type is RequestTokenTypes.Private.PushedAuthorizationRequest) { return; } @@ -1670,14 +1872,24 @@ public static partial class OpenIddictServerHandlers var application = await _applicationManager.FindByClientIdAsync(context.ClientId) ?? throw new InvalidOperationException(SR.GetResourceString(SR.ID0032)); - if (await _applicationManager.HasRequirementAsync(application, Requirements.Features.ProofKeyForCodeExchange)) + if (await _applicationManager.HasRequirementAsync(application, Requirements.Features.PushedAuthorizationRequests)) { - context.Logger.LogInformation(SR.GetResourceString(SR.ID6033), Parameters.CodeChallenge); + if (string.IsNullOrEmpty(context.Request.RequestUri)) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6033), Parameters.RequestUri); + + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2054(Parameters.RequestUri), + uri: SR.FormatID8000(SR.ID2054)); + + return; + } context.Reject( error: Errors.InvalidRequest, - description: SR.FormatID2054(Parameters.CodeChallenge), - uri: SR.FormatID8000(SR.ID2054)); + description: SR.FormatID2182(Parameters.RequestUri), + uri: SR.FormatID8000(SR.ID2182)); return; } @@ -1685,8 +1897,67 @@ public static partial class OpenIddictServerHandlers } /// - /// Contains the logic responsible for rejecting authorization requests that specify an identity - /// token hint that cannot be used by the client application sending the authorization request. + /// Contains the logic responsible for rejecting authorization requests made by + /// applications for which proof key for code exchange (PKCE) was enforced. + /// Note: this handler is not used when the degraded mode is enabled. + /// + public sealed class ValidateProofKeyForCodeExchangeRequirement : IOpenIddictServerHandler + { + private readonly IOpenIddictApplicationManager _applicationManager; + + public ValidateProofKeyForCodeExchangeRequirement() => throw new InvalidOperationException(SR.GetResourceString(SR.ID0016)); + + public ValidateProofKeyForCodeExchangeRequirement(IOpenIddictApplicationManager applicationManager) + => _applicationManager = applicationManager ?? throw new ArgumentNullException(nameof(applicationManager)); + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseScopedHandler() + .SetOrder(ValidatePushedAuthorizationRequestsRequirement.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public async ValueTask HandleAsync(ValidateAuthorizationRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(!string.IsNullOrEmpty(context.ClientId), SR.FormatID4000(Parameters.ClientId)); + + // If a code_challenge was provided or if no authorization code is requested, the request is always + // considered valid, whether the proof key for code exchange requirement is enforced or not. + if (!string.IsNullOrEmpty(context.Request.CodeChallenge) || !context.Request.HasResponseType(ResponseTypes.Code)) + { + return; + } + + var application = await _applicationManager.FindByClientIdAsync(context.ClientId) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0032)); + + if (await _applicationManager.HasRequirementAsync(application, Requirements.Features.ProofKeyForCodeExchange)) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6033), Parameters.CodeChallenge); + + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2054(Parameters.CodeChallenge), + uri: SR.FormatID8000(SR.ID2054)); + + return; + } + } + } + + /// + /// Contains the logic responsible for rejecting authorization requests that specify an identity + /// token hint that cannot be used by the client application sending the authorization request. /// public sealed class ValidateAuthorizedParty : IOpenIddictServerHandler { @@ -1792,7 +2063,9 @@ public static partial class OpenIddictServerHandlers throw new ArgumentNullException(nameof(context)); } - if (context.Request is null) + // If the authorization response contains a request token, do not use the + // redirect_uri, as the user agent will be redirected to the same page. + if (context.Request is null || !string.IsNullOrEmpty(context.Response.RequestUri)) { return default; } @@ -1843,7 +2116,7 @@ public static partial class OpenIddictServerHandlers context.ResponseMode = context.Request.ResponseMode; // If the response_mode parameter was not specified, try to infer it. - if (string.IsNullOrEmpty(context.ResponseMode) && !string.IsNullOrEmpty(context.RedirectUri)) + if (!string.IsNullOrEmpty(context.RedirectUri) && string.IsNullOrEmpty(context.ResponseMode)) { context.ResponseMode = context.Request.IsFormPostResponseMode() ? ResponseModes.FormPost : context.Request.IsFragmentResponseMode() ? ResponseModes.Fragment : @@ -1935,5 +2208,1722 @@ public static partial class OpenIddictServerHandlers return default; } } + + /// + /// Contains the logic responsible for extracting pushed authorization + /// requests and invoking the corresponding event handlers. + /// + public sealed class ExtractPushedAuthorizationRequest : IOpenIddictServerHandler + { + private readonly IOpenIddictServerDispatcher _dispatcher; + + public ExtractPushedAuthorizationRequest(IOpenIddictServerDispatcher dispatcher) + => _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseScopedHandler() + .SetOrder(100_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public async ValueTask HandleAsync(ProcessRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + var notification = new ExtractPushedAuthorizationRequestContext(context.Transaction); + 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; + } + + if (notification.Request is null) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0468)); + } + + context.Logger.LogInformation(SR.GetResourceString(SR.ID6237), notification.Request); + } + } + + /// + /// Contains the logic responsible for validating pushed authorization + /// requests and invoking the corresponding event handlers. + /// + public sealed class ValidatePushedAuthorizationRequest : IOpenIddictServerHandler + { + private readonly IOpenIddictServerDispatcher _dispatcher; + + public ValidatePushedAuthorizationRequest(IOpenIddictServerDispatcher dispatcher) + => _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseScopedHandler() + .SetOrder(ExtractPushedAuthorizationRequest.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public async ValueTask HandleAsync(ProcessRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + var notification = new ValidatePushedAuthorizationRequestContext(context.Transaction); + await _dispatcher.DispatchAsync(notification); + + // Store the context object in the transaction so it can be later retrieved by handlers + // that want to access the redirect_uri without triggering a new validation process. + context.Transaction.SetProperty(typeof(ValidatePushedAuthorizationRequestContext).FullName!, 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; + } + + if (string.IsNullOrEmpty(notification.RedirectUri)) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0028)); + } + + context.Logger.LogInformation(SR.GetResourceString(SR.ID6238)); + } + } + + /// + /// Contains the logic responsible for handling pushed authorization + /// requests and invoking the corresponding event handlers. + /// + public sealed class HandlePushedAuthorizationRequest : IOpenIddictServerHandler + { + private readonly IOpenIddictServerDispatcher _dispatcher; + + public HandlePushedAuthorizationRequest(IOpenIddictServerDispatcher dispatcher) + => _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseScopedHandler() + .SetOrder(ValidatePushedAuthorizationRequest.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public async ValueTask HandleAsync(ProcessRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + var notification = new HandlePushedAuthorizationRequestContext(context.Transaction); + 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; + } + + if (notification.Principal is null) + { + // Note: no authentication type is deliberately specified to represent an unauthenticated identity. + var principal = new ClaimsPrincipal(new ClaimsIdentity()); + principal.SetScopes(notification.Request.GetScopes()); + + notification.Principal = principal; + } + + var @event = new ProcessSignInContext(context.Transaction) + { + Principal = notification.Principal, + Response = new OpenIddictResponse() + }; + + if (notification.Parameters.Count > 0) + { + foreach (var parameter in notification.Parameters) + { + @event.Parameters.Add(parameter.Key, parameter.Value); + } + } + + await _dispatcher.DispatchAsync(@event); + + if (@event.IsRequestHandled) + { + context.HandleRequest(); + return; + } + + else if (@event.IsRequestSkipped) + { + context.SkipRequest(); + return; + } + + else if (@event.IsRejected) + { + context.Reject( + error: @event.Error ?? Errors.InvalidGrant, + description: @event.ErrorDescription, + uri: @event.ErrorUri); + return; + } + } + } + + /// + /// Contains the logic responsible for processing pushed authorization + /// responses and invoking the corresponding event handlers. + /// + public sealed class ApplyPushedAuthorizationResponse : IOpenIddictServerHandler where TContext : BaseRequestContext + { + private readonly IOpenIddictServerDispatcher _dispatcher; + + public ApplyPushedAuthorizationResponse(IOpenIddictServerDispatcher dispatcher) + => _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseScopedHandler>() + .SetOrder(500_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public async ValueTask HandleAsync(TContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + var notification = new ApplyPushedAuthorizationResponseContext(context.Transaction); + await _dispatcher.DispatchAsync(notification); + + if (notification.IsRequestHandled) + { + context.HandleRequest(); + return; + } + + else if (notification.IsRequestSkipped) + { + context.SkipRequest(); + return; + } + + throw new InvalidOperationException(SR.GetResourceString(SR.ID0469)); + } + } + + /// + /// Contains the logic responsible for rejecting pushed authorization + /// requests that specify the unsupported request parameter. + /// + public sealed class ValidatePushedRequestParameter : IOpenIddictServerHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(int.MinValue + 100_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ValidatePushedAuthorizationRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // Reject requests using the unsupported request parameter. + if (!string.IsNullOrEmpty(context.Request.Request)) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6239), Parameters.Request); + + context.Reject( + error: Errors.RequestNotSupported, + description: SR.FormatID2028(Parameters.Request), + uri: SR.FormatID8000(SR.ID2028)); + + return default; + } + + return default; + } + } + + /// + /// Contains the logic responsible for rejecting pushed authorization + /// requests that specify the forbidden request_uri parameter. + /// + public sealed class ValidatePushedRequestUriParameter : IOpenIddictServerHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ValidatePushedRequestParameter.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ValidatePushedAuthorizationRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // Reject requests using the request_uri parameter, as this parameter is explicitly forbidden + // by the OAuth 2.0 Pushed Authorization Requests specification when used in PAR requests. + // + // See https://datatracker.ietf.org/doc/html/rfc9126#section-2.1 for more information. + if (!string.IsNullOrEmpty(context.Request.RequestUri)) + { + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2074(Parameters.RequestUri), + uri: SR.FormatID8000(SR.ID2074)); + + return default; + } + + return default; + } + } + + /// + /// Contains the logic responsible for rejecting pushed authorization + /// requests that lack the mandatory client_id parameter. + /// + public sealed class ValidatePushedClientIdParameter : IOpenIddictServerHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ValidatePushedRequestUriParameter.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ValidatePushedAuthorizationRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // client_id is a required parameter and MUST cause an error when missing. + // See http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest. + if (string.IsNullOrEmpty(context.ClientId)) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6240), Parameters.ClientId); + + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2029(Parameters.ClientId), + uri: SR.FormatID8000(SR.ID2029)); + + return default; + } + + return default; + } + } + + /// + /// Contains the logic responsible for rejecting pushed authorization + /// requests that lack the mandatory redirect_uri parameter. + /// + public sealed class ValidatePushedRedirectUriParameter : IOpenIddictServerHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ValidatePushedClientIdParameter.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ValidatePushedAuthorizationRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // While redirect_uri was not mandatory in OAuth 2.0, this parameter + // is now declared as REQUIRED and MUST cause an error when missing. + // See http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest. + // To keep OpenIddict compatible with pure OAuth 2.0 clients, an error + // is only returned if the request was made by an OpenID Connect client. + if (string.IsNullOrEmpty(context.RedirectUri)) + { + if (context.Request.HasScope(Scopes.OpenId)) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6240), Parameters.RedirectUri); + + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2029(Parameters.RedirectUri), + uri: SR.FormatID8000(SR.ID2029)); + + return default; + } + + return default; + } + + // Note: when specified, redirect_uri MUST be an absolute URI. + // See http://tools.ietf.org/html/rfc6749#section-3.1.2 + // and http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest. + if (!Uri.TryCreate(context.RedirectUri, UriKind.Absolute, out Uri? uri) || OpenIddictHelpers.IsImplicitFileUri(uri)) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6241), Parameters.RedirectUri, context.RedirectUri); + + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2030(Parameters.RedirectUri), + uri: SR.FormatID8000(SR.ID2030)); + + return default; + } + + // Note: when specified, redirect_uri MUST NOT include a fragment component. + // See http://tools.ietf.org/html/rfc6749#section-3.1.2 + // and http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest + if (!string.IsNullOrEmpty(uri.Fragment)) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6242), Parameters.RedirectUri, context.RedirectUri); + + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2031(Parameters.RedirectUri), + uri: SR.FormatID8000(SR.ID2031)); + + return default; + } + + // To prevent issuer fixation attacks where a malicious client would specify an "iss" parameter + // in the redirect_uri, ensure the query - if present - doesn't include an "iss" parameter. + // + // Note: while OAuth 2.0 parameters are case-sentitive, the following check deliberately + // uses a case-insensitive comparison to ensure that all variations of "iss" are rejected. + if (!string.IsNullOrEmpty(uri.Query)) + { + var parameters = OpenIddictHelpers.ParseQuery(uri.Query); + if (parameters.ContainsKey(Parameters.Iss)) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6259), Parameters.RedirectUri, Parameters.Iss); + + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2135(Parameters.RedirectUri, Parameters.Iss), + uri: SR.FormatID8000(SR.ID2135)); + + return default; + } + } + + return default; + } + } + + /// + /// Contains the logic responsible for rejecting pushed authorization + /// requests that specify an invalid response_type parameter. + /// + public sealed class ValidatePushedResponseTypeParameter : IOpenIddictServerHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ValidatePushedRedirectUriParameter.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ValidatePushedAuthorizationRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // Reject requests missing the mandatory response_type parameter. + if (string.IsNullOrEmpty(context.Request.ResponseType)) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6240), Parameters.ResponseType); + + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2029(Parameters.ResponseType), + uri: SR.FormatID8000(SR.ID2029)); + + return default; + } + + // Reject code flow requests if the server is not configured to allow the authorization code grant type. + if (context.Request.IsAuthorizationCodeFlow() && !context.Options.GrantTypes.Contains(GrantTypes.AuthorizationCode)) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6243), context.Request.ResponseType); + + context.Reject( + error: Errors.UnsupportedResponseType, + description: SR.FormatID2032(Parameters.ResponseType), + uri: SR.FormatID8000(SR.ID2032)); + + return default; + } + + // Reject implicit flow requests if the server is not configured to allow the implicit grant type. + if (context.Request.IsImplicitFlow() && !context.Options.GrantTypes.Contains(GrantTypes.Implicit)) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6243), context.Request.ResponseType); + + context.Reject( + error: Errors.UnsupportedResponseType, + description: SR.FormatID2032(Parameters.ResponseType), + uri: SR.FormatID8000(SR.ID2032)); + + return default; + } + + // Reject hybrid flow requests if the server is not configured to allow the authorization code or implicit grant types. + if (context.Request.IsHybridFlow() && (!context.Options.GrantTypes.Contains(GrantTypes.AuthorizationCode) || + !context.Options.GrantTypes.Contains(GrantTypes.Implicit))) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6243), context.Request.ResponseType); + + context.Reject( + error: Errors.UnsupportedResponseType, + description: SR.FormatID2032(Parameters.ResponseType), + uri: SR.FormatID8000(SR.ID2032)); + + return default; + } + + // Prevent response_type=none from being used with any other value. + // See https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#none for more information. + var types = context.Request.GetResponseTypes().ToHashSet(StringComparer.Ordinal); + if (types.Count > 1 && types.Contains(ResponseTypes.None)) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6260), context.Request.ResponseType); + + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2052(Parameters.ResponseType), + uri: SR.FormatID8000(SR.ID2052)); + + return default; + } + + // Reject requests that specify an unsupported response_type. + if (!context.Options.ResponseTypes.Any(type => types.SetEquals( + type.Split(Separators.Space, StringSplitOptions.RemoveEmptyEntries)))) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6243), context.Request.ResponseType); + + context.Reject( + error: Errors.UnsupportedResponseType, + description: SR.FormatID2032(Parameters.ResponseType), + uri: SR.FormatID8000(SR.ID2032)); + + return default; + } + + return default; + } + } + + /// + /// Contains the logic responsible for rejecting pushed authorization + /// requests that specify an invalid response_mode parameter. + /// + public sealed class ValidatePushedResponseModeParameter : IOpenIddictServerHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ValidatePushedResponseTypeParameter.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ValidatePushedAuthorizationRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // response_mode=query (explicit or not) and a response_type containing id_token + // or token are not considered as a safe combination and MUST be rejected. + // See http://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#Security. + if (context.Request.IsQueryResponseMode() && (context.Request.HasResponseType(ResponseTypes.IdToken) || + context.Request.HasResponseType(ResponseTypes.Token))) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6244), context.Request.ResponseType, context.Request.ResponseMode); + + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2033(Parameters.ResponseType, Parameters.ResponseMode), + uri: SR.FormatID8000(SR.ID2033)); + + return default; + } + + // Reject requests that specify an unsupported response_mode or don't specify a different response_mode + // if the default response_mode inferred from the response_type was explicitly disabled in the options. + if (!ValidatePushedResponseMode(context.Request, context.Options)) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6245), context.Request.ResponseMode); + + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2032(Parameters.ResponseMode), + uri: SR.FormatID8000(SR.ID2032)); + + return default; + } + + return default; + + static bool ValidatePushedResponseMode(OpenIddictRequest request, OpenIddictServerOptions options) + { + // Note: both the fragment and query response modes are used as default response modes + // when using the implicit/hybrid and code flows if no explicit value was set. + // To ensure requests are rejected if the default response mode was manually disabled, + // the fragment and query response modes are checked first using the appropriate extensions. + + if (request.IsFragmentResponseMode()) + { + return options.ResponseModes.Contains(ResponseModes.Fragment); + } + + if (request.IsQueryResponseMode()) + { + return options.ResponseModes.Contains(ResponseModes.Query); + } + + if (string.IsNullOrEmpty(request.ResponseMode)) + { + return true; + } + + return options.ResponseModes.Contains(request.ResponseMode); + } + } + } + + /// + /// Contains the logic responsible for rejecting pushed authorization + /// requests that don't specify a valid scope parameter. + /// + public sealed class ValidatePushedScopeParameter : IOpenIddictServerHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ValidatePushedResponseModeParameter.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ValidatePushedAuthorizationRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // Reject pushed authorization requests containing the id_token response_type if no openid scope has been received. + if (context.Request.HasResponseType(ResponseTypes.IdToken) && !context.Request.HasScope(Scopes.OpenId)) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6246), Scopes.OpenId); + + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2034(Scopes.OpenId), + uri: SR.FormatID8000(SR.ID2034)); + + return default; + } + + // Reject pushed authorization requests that specify scope=offline_access if the refresh token flow is not enabled. + if (context.Request.HasScope(Scopes.OfflineAccess) && !context.Options.GrantTypes.Contains(GrantTypes.RefreshToken)) + { + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2035(Scopes.OfflineAccess), + uri: SR.FormatID8000(SR.ID2035)); + + return default; + } + + return default; + } + } + + /// + /// Contains the logic responsible for rejecting pushed authorization requests that don't specify a nonce. + /// + public sealed class ValidatePushedNonceParameter : IOpenIddictServerHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ValidatePushedScopeParameter.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ValidatePushedAuthorizationRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // Reject OpenID Connect implicit/hybrid requests missing the mandatory nonce parameter. + // See http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest, + // http://openid.net/specs/openid-connect-implicit-1_0.html#RequestParameters + // and http://openid.net/specs/openid-connect-core-1_0.html#HybridIDToken. + + if (!string.IsNullOrEmpty(context.Request.Nonce) || !context.Request.HasScope(Scopes.OpenId)) + { + return default; + } + + if (context.Request.IsImplicitFlow() || context.Request.IsHybridFlow()) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6240), Parameters.Nonce); + + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2029(Parameters.Nonce), + uri: SR.FormatID8000(SR.ID2029)); + + return default; + } + + return default; + } + } + + /// + /// Contains the logic responsible for rejecting pushed authorization requests that don't specify a valid prompt parameter. + /// + public sealed class ValidatePushedPromptParameter : IOpenIddictServerHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ValidatePushedNonceParameter.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ValidatePushedAuthorizationRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (string.IsNullOrEmpty(context.Request.Prompt)) + { + return default; + } + + // Reject requests specifying an unsupported prompt value. + // See https://openid.net/specs/openid-connect-prompt-create-1_0.html#section-4.1 for more information. + foreach (var value in context.Request.GetPromptValues().ToHashSet(StringComparer.Ordinal)) + { + if (!context.Options.PromptValues.Contains(value)) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6261)); + + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2032(Parameters.Prompt), + uri: SR.FormatID8000(SR.ID2032)); + + return default; + } + } + + // Reject requests specifying prompt=none with consent/login or select_account. + // See https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest for more information. + if (context.Request.HasPromptValue(PromptValues.None) && (context.Request.HasPromptValue(PromptValues.Consent) || + context.Request.HasPromptValue(PromptValues.Login) || + context.Request.HasPromptValue(PromptValues.SelectAccount))) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6247)); + + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2052(Parameters.Prompt), + uri: SR.FormatID8000(SR.ID2052)); + + return default; + } + + return default; + } + } + + /// + /// Contains the logic responsible for rejecting pushed authorization requests that don't specify valid PKCE parameters. + /// + public sealed class ValidatePushedProofKeyForCodeExchangeParameters : IOpenIddictServerHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ValidatePushedPromptParameter.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ValidatePushedAuthorizationRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // If OpenIddict was configured to require PKCE, reject the request if the code challenge + // is missing and if an authorization code was requested by the client application. + if (context.Options.RequireProofKeyForCodeExchange && + context.Request.HasResponseType(ResponseTypes.Code) && + string.IsNullOrEmpty(context.Request.CodeChallenge)) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6240), Parameters.CodeChallenge); + + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2029(Parameters.CodeChallenge), + uri: SR.FormatID8000(SR.ID2029)); + + return default; + } + + // At this point, stop validating the PKCE parameters if both the + // code_challenge and code_challenge_method parameter are missing. + if (string.IsNullOrEmpty(context.Request.CodeChallenge) && + string.IsNullOrEmpty(context.Request.CodeChallengeMethod)) + { + return default; + } + + // Ensure a code_challenge was specified if a code_challenge_method was used. + if (string.IsNullOrEmpty(context.Request.CodeChallenge)) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6240), Parameters.CodeChallenge); + + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2037(Parameters.CodeChallengeMethod, Parameters.CodeChallenge), + uri: SR.FormatID8000(SR.ID2037)); + + return default; + } + + // If the plain code challenge method was not explicitly enabled, + // reject the request indicating that a method must be set. + if (string.IsNullOrEmpty(context.Request.CodeChallengeMethod) && + !context.Options.CodeChallengeMethods.Contains(CodeChallengeMethods.Plain)) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6240), Parameters.CodeChallengeMethod); + + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2029(Parameters.CodeChallengeMethod), + uri: SR.FormatID8000(SR.ID2029)); + + return default; + } + + // If a code_challenge_method was specified, ensure the algorithm is supported. + if (!string.IsNullOrEmpty(context.Request.CodeChallengeMethod) && + !context.Options.CodeChallengeMethods.Contains(context.Request.CodeChallengeMethod)) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6248)); + + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2032(Parameters.CodeChallengeMethod), + uri: SR.FormatID8000(SR.ID2032)); + + return default; + } + + // When code_challenge or code_challenge_method is specified, ensure the response_type includes "code". + if (!context.Request.HasResponseType(ResponseTypes.Code)) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6249)); + + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2040(Parameters.CodeChallenge, Parameters.CodeChallengeMethod, ResponseTypes.Code), + uri: SR.FormatID8000(SR.ID2040)); + + return default; + } + + return default; + } + } + + /// + /// Contains the logic responsible for applying the authentication logic to pushed authorization requests. + /// + public sealed class ValidatePushedAuthentication : IOpenIddictServerHandler + { + private readonly IOpenIddictServerDispatcher _dispatcher; + + public ValidatePushedAuthentication(IOpenIddictServerDispatcher dispatcher) + => _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .UseScopedHandler() + .SetOrder(ValidatePushedProofKeyForCodeExchangeParameters.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public async ValueTask HandleAsync(ValidatePushedAuthorizationRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + var notification = new ProcessAuthenticationContext(context.Transaction); + await _dispatcher.DispatchAsync(notification); + + // Store the context object in the transaction so it can be later retrieved by handlers + // that want to access the authentication result without triggering a new authentication flow. + context.Transaction.SetProperty(typeof(ProcessAuthenticationContext).FullName!, 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; + } + + // Attach the security principal extracted from the token to the validation context. + context.IdentityTokenHintPrincipal = notification.IdentityTokenPrincipal; + } + } + + /// + /// Contains the logic responsible for rejecting pushed authorization requests that use an unsafe response type. + /// + public sealed class ValidatePushedResponseType : IOpenIddictServerHandler + { + private readonly IOpenIddictApplicationManager? _applicationManager; + + public ValidatePushedResponseType(IOpenIddictApplicationManager? applicationManager = null) + => _applicationManager = applicationManager; + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .UseScopedHandler(static provider => + { + // Note: the application manager is only resolved if the degraded mode was not enabled to ensure + // invalid core configuration exceptions are not thrown even if the managers were registered. + var options = provider.GetRequiredService>().CurrentValue; + + return options.EnableDegradedMode ? + new ValidatePushedResponseType(applicationManager: null) : + new ValidatePushedResponseType(provider.GetService() ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0016))); + }) + .SetOrder(ValidatePushedAuthentication.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public async ValueTask HandleAsync(ValidatePushedAuthorizationRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // Note: this handler is responsible for enforcing additional response_type requirements when + // response type permissions are not used (and thus cannot be finely controlled per client). + // + // Users who want to support the scenarios disallowed by this event handler are encouraged + // to re-enable permissions validation. Alternatively, this handler can be removed from + // the handlers list and replaced by a custom version using the events model APIs. + if (!context.Options.IgnoreResponseTypePermissions) + { + return; + } + + Debug.Assert(!string.IsNullOrEmpty(context.ClientId), SR.FormatID4000(Parameters.ClientId)); + + // When PKCE is used, reject pushed authorization requests returning an access token directly + // from the authorization endpoint to prevent a malicious client from retrieving a valid + // access token - even with a limited scope - without sending the correct code_verifier. + if (!string.IsNullOrEmpty(context.Request.CodeChallenge) && + context.Request.HasResponseType(ResponseTypes.Token)) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6250)); + + context.Reject( + error: Errors.UnauthorizedClient, + description: SR.FormatID2041(Parameters.ResponseType), + uri: SR.FormatID8000(SR.ID2041)); + + return; + } + + if (!context.Options.EnableDegradedMode) + { + if (_applicationManager is null) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0016)); + } + + var application = await _applicationManager.FindByClientIdAsync(context.ClientId) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0032)); + + // To prevent downgrade attacks, ensure that pushed authorization requests returning + // an access token directly from the authorization endpoint are rejected if + // the client_id corresponds to a confidential application. + if (context.Request.HasResponseType(ResponseTypes.Token) && + await _applicationManager.HasClientTypeAsync(application, ClientTypes.Confidential)) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6251), context.ClientId); + + context.Reject( + error: Errors.UnauthorizedClient, + description: SR.FormatID2043(Parameters.ResponseType), + uri: SR.FormatID8000(SR.ID2043)); + + return; + } + } + } + } + + /// + /// Contains the logic responsible for rejecting pushed authorization requests that use an invalid redirect_uri. + /// Note: this handler is not used when the degraded mode is enabled. + /// + public sealed class ValidatePushedClientRedirectUri : IOpenIddictServerHandler + { + private readonly IOpenIddictApplicationManager _applicationManager; + + public ValidatePushedClientRedirectUri() => throw new InvalidOperationException(SR.GetResourceString(SR.ID0016)); + + public ValidatePushedClientRedirectUri(IOpenIddictApplicationManager applicationManager) + => _applicationManager = applicationManager ?? throw new ArgumentNullException(nameof(applicationManager)); + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseScopedHandler() + .SetOrder(ValidatePushedResponseType.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public async ValueTask HandleAsync(ValidatePushedAuthorizationRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(!string.IsNullOrEmpty(context.ClientId), SR.FormatID4000(Parameters.ClientId)); + + var application = await _applicationManager.FindByClientIdAsync(context.ClientId) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0032)); + + // If no explicit redirect_uri was specified, retrieve the URI associated with the + // client and ensure exactly one redirect_uri was attached to the client definition. + if (string.IsNullOrEmpty(context.RedirectUri)) + { + var uris = await _applicationManager.GetRedirectUrisAsync(application); + if (uris.Length is not 1) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6240), Parameters.RedirectUri); + + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2029(Parameters.RedirectUri), + uri: SR.FormatID8000(SR.ID2029)); + + return; + } + + context.SetRedirectUri(uris[0]); + + return; + } + + // Otherwise, ensure that the specified redirect_uri is valid and is associated with the client application. + if (!await _applicationManager.ValidateRedirectUriAsync(application, context.RedirectUri)) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6252), context.RedirectUri); + + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2043(Parameters.RedirectUri), + uri: SR.FormatID8000(SR.ID2043)); + + return; + } + } + } + + /// + /// Contains the logic responsible for rejecting pushed authorization requests that use unregistered scopes. + /// Note: this handler partially works with the degraded mode but is not used when scope validation is disabled. + /// + public sealed class ValidatePushedScopes : IOpenIddictServerHandler + { + private readonly IOpenIddictScopeManager? _scopeManager; + + public ValidatePushedScopes(IOpenIddictScopeManager? scopeManager = null) + => _scopeManager = scopeManager; + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseScopedHandler(static provider => + { + // Note: the scope manager is only resolved if the degraded mode was not enabled to ensure + // invalid core configuration exceptions are not thrown even if the managers were registered. + var options = provider.GetRequiredService>().CurrentValue; + + return options.EnableDegradedMode ? + new ValidatePushedScopes() : + new ValidatePushedScopes(provider.GetService() ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0016))); + }) + .SetOrder(ValidatePushedClientRedirectUri.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public async ValueTask HandleAsync(ValidatePushedAuthorizationRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // If all the specified scopes are registered in the options, avoid making a database lookup. + var scopes = context.Request.GetScopes().ToHashSet(StringComparer.Ordinal); + scopes.ExceptWith(context.Options.Scopes); + + // Note: the remaining scopes are only checked if the degraded mode was not enabled, + // as this requires using the scope manager, which is never used with the degraded mode, + // even if the service was registered and resolved from the dependency injection container. + if (scopes.Count is not 0 && !context.Options.EnableDegradedMode) + { + if (_scopeManager is null) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0016)); + } + + await foreach (var scope in _scopeManager.FindByNamesAsync(scopes.ToImmutableArray())) + { + var name = await _scopeManager.GetNameAsync(scope); + if (!string.IsNullOrEmpty(name)) + { + scopes.Remove(name); + } + } + } + + // If at least one scope was not recognized, return an error. + if (scopes.Count is not 0) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6253), scopes); + + context.Reject( + error: Errors.InvalidScope, + description: SR.FormatID2052(Parameters.Scope), + uri: SR.FormatID8000(SR.ID2052)); + + return; + } + } + } + + /// + /// Contains the logic responsible for rejecting pushed authorization requests made by unauthorized applications. + /// Note: this handler is not used when the degraded mode is enabled or when endpoint permissions are disabled. + /// + public sealed class ValidatePushedEndpointPermissions : IOpenIddictServerHandler + { + private readonly IOpenIddictApplicationManager _applicationManager; + + public ValidatePushedEndpointPermissions() => throw new InvalidOperationException(SR.GetResourceString(SR.ID0016)); + + public ValidatePushedEndpointPermissions(IOpenIddictApplicationManager applicationManager) + => _applicationManager = applicationManager ?? throw new ArgumentNullException(nameof(applicationManager)); + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() + .AddFilter() + .UseScopedHandler() + .SetOrder(ValidatePushedScopes.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public async ValueTask HandleAsync(ValidatePushedAuthorizationRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(!string.IsNullOrEmpty(context.ClientId), SR.FormatID4000(Parameters.ClientId)); + + var application = await _applicationManager.FindByClientIdAsync(context.ClientId) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0032)); + + // Reject the request if the application is not allowed to use the pushed authorization endpoint. + if (!await _applicationManager.HasPermissionAsync(application, Permissions.Endpoints.PushedAuthorization)) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6254), context.ClientId); + + context.Reject( + error: Errors.UnauthorizedClient, + description: SR.GetResourceString(SR.ID2183), + uri: SR.FormatID8000(SR.ID2183)); + + return; + } + } + } + + /// + /// Contains the logic responsible for rejecting pushed authorization requests made by unauthorized applications. + /// Note: this handler is not used when the degraded mode is enabled or when grant type permissions are disabled. + /// + public sealed class ValidatePushedGrantTypePermissions : IOpenIddictServerHandler + { + private readonly IOpenIddictApplicationManager _applicationManager; + + public ValidatePushedGrantTypePermissions() => throw new InvalidOperationException(SR.GetResourceString(SR.ID0016)); + + public ValidatePushedGrantTypePermissions(IOpenIddictApplicationManager applicationManager) + => _applicationManager = applicationManager ?? throw new ArgumentNullException(nameof(applicationManager)); + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() + .AddFilter() + .UseScopedHandler() + .SetOrder(ValidatePushedEndpointPermissions.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public async ValueTask HandleAsync(ValidatePushedAuthorizationRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(!string.IsNullOrEmpty(context.ClientId), SR.FormatID4000(Parameters.ClientId)); + + var application = await _applicationManager.FindByClientIdAsync(context.ClientId) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0032)); + + // Reject the request if the application is not allowed to use the authorization code grant. + if (context.Request.IsAuthorizationCodeFlow() && + !await _applicationManager.HasPermissionAsync(application, Permissions.GrantTypes.AuthorizationCode)) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6255), context.ClientId); + + context.Reject( + error: Errors.UnauthorizedClient, + description: SR.GetResourceString(SR.ID2047), + uri: SR.FormatID8000(SR.ID2047)); + + return; + } + + // Reject the request if the application is not allowed to use the implicit grant. + if (context.Request.IsImplicitFlow() && + !await _applicationManager.HasPermissionAsync(application, Permissions.GrantTypes.Implicit)) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6256), context.ClientId); + + context.Reject( + error: Errors.UnauthorizedClient, + description: SR.GetResourceString(SR.ID2048), + uri: SR.FormatID8000(SR.ID2048)); + + return; + } + + // Reject the request if the application is not allowed to use the authorization code/implicit grants. + if (context.Request.IsHybridFlow() && + (!await _applicationManager.HasPermissionAsync(application, Permissions.GrantTypes.AuthorizationCode) || + !await _applicationManager.HasPermissionAsync(application, Permissions.GrantTypes.Implicit))) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6257), context.ClientId); + + context.Reject( + error: Errors.UnauthorizedClient, + description: SR.GetResourceString(SR.ID2049), + uri: SR.FormatID8000(SR.ID2049)); + + return; + } + + // Reject the request if the offline_access scope was request and + // if the application is not allowed to use the refresh token grant. + if (context.Request.HasScope(Scopes.OfflineAccess) && + !await _applicationManager.HasPermissionAsync(application, Permissions.GrantTypes.RefreshToken)) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6258), context.ClientId, Scopes.OfflineAccess); + + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2065(Scopes.OfflineAccess), + uri: SR.FormatID8000(SR.ID2065)); + + return; + } + } + } + + /// + /// Contains the logic responsible for rejecting pushed authorization requests made by unauthorized applications. + /// Note: this handler is not used when the degraded mode is enabled or when grant type permissions are disabled. + /// + public sealed class ValidatePushedResponseTypePermissions : IOpenIddictServerHandler + { + private readonly IOpenIddictApplicationManager _applicationManager; + + public ValidatePushedResponseTypePermissions() => throw new InvalidOperationException(SR.GetResourceString(SR.ID0016)); + + public ValidatePushedResponseTypePermissions(IOpenIddictApplicationManager applicationManager) + => _applicationManager = applicationManager ?? throw new ArgumentNullException(nameof(applicationManager)); + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() + .AddFilter() + .UseScopedHandler() + .SetOrder(ValidatePushedGrantTypePermissions.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public async ValueTask HandleAsync(ValidatePushedAuthorizationRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(!string.IsNullOrEmpty(context.ClientId), SR.FormatID4000(Parameters.ClientId)); + + var application = await _applicationManager.FindByClientIdAsync(context.ClientId) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0032)); + + // Reject requests that specify a response_type for which no permission was granted. + if (!await HasPermissionAsync(context.Request.GetResponseTypes())) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6262), context.ClientId, context.Request.ResponseType); + + context.Reject( + error: Errors.UnauthorizedClient, + description: SR.FormatID2043(Parameters.ResponseType), + uri: SR.FormatID8000(SR.ID2043)); + + return; + } + + async ValueTask HasPermissionAsync(IEnumerable types) + { + // Note: response type permissions are always prefixed with "rst:". + const string prefix = Permissions.Prefixes.ResponseType; + + foreach (var permission in await _applicationManager.GetPermissionsAsync(application)) + { + // Ignore permissions that are not response type permissions. + if (!permission.StartsWith(prefix, StringComparison.Ordinal)) + { + continue; + } + + // Note: response types can be specified in any order. To ensure permissions are correctly + // checked even if the order differs from the one specified in the request, a HashSet is used. + var values = permission[prefix.Length..].Split(Separators.Space, StringSplitOptions.RemoveEmptyEntries); + if (values.Length is not 0 && values.ToHashSet(StringComparer.Ordinal).SetEquals(types)) + { + return true; + } + } + + return false; + } + } + } + + /// + /// Contains the logic responsible for rejecting pushed authorization requests made by unauthorized applications. + /// Note: this handler is not used when the degraded mode is enabled or when scope permissions are disabled. + /// + public sealed class ValidatePushedScopePermissions : IOpenIddictServerHandler + { + private readonly IOpenIddictApplicationManager _applicationManager; + + public ValidatePushedScopePermissions() => throw new InvalidOperationException(SR.GetResourceString(SR.ID0016)); + + public ValidatePushedScopePermissions(IOpenIddictApplicationManager applicationManager) + => _applicationManager = applicationManager ?? throw new ArgumentNullException(nameof(applicationManager)); + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() + .AddFilter() + .UseScopedHandler() + .SetOrder(ValidatePushedResponseTypePermissions.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public async ValueTask HandleAsync(ValidatePushedAuthorizationRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(!string.IsNullOrEmpty(context.ClientId), SR.FormatID4000(Parameters.ClientId)); + + var application = await _applicationManager.FindByClientIdAsync(context.ClientId) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0032)); + + foreach (var scope in context.Request.GetScopes()) + { + // Avoid validating the "openid" and "offline_access" scopes as they represent protocol scopes. + if (string.Equals(scope, Scopes.OfflineAccess, StringComparison.Ordinal) || + string.Equals(scope, Scopes.OpenId, StringComparison.Ordinal)) + { + continue; + } + + // Reject the request if the application is not allowed to use the iterated scope. + if (!await _applicationManager.HasPermissionAsync(application, Permissions.Prefixes.Scope + scope)) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6258), context.ClientId, scope); + + context.Reject( + error: Errors.InvalidRequest, + description: SR.GetResourceString(SR.ID2051), + uri: SR.FormatID8000(SR.ID2051)); + + return; + } + } + } + } + + /// + /// Contains the logic responsible for rejecting pushed authorization requests made by + /// applications for which proof key for code exchange (PKCE) was enforced. + /// Note: this handler is not used when the degraded mode is enabled. + /// + public sealed class ValidatePushedProofKeyForCodeExchangeRequirement : IOpenIddictServerHandler + { + private readonly IOpenIddictApplicationManager _applicationManager; + + public ValidatePushedProofKeyForCodeExchangeRequirement() => throw new InvalidOperationException(SR.GetResourceString(SR.ID0016)); + + public ValidatePushedProofKeyForCodeExchangeRequirement(IOpenIddictApplicationManager applicationManager) + => _applicationManager = applicationManager ?? throw new ArgumentNullException(nameof(applicationManager)); + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseScopedHandler() + .SetOrder(ValidatePushedScopePermissions.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public async ValueTask HandleAsync(ValidatePushedAuthorizationRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(!string.IsNullOrEmpty(context.ClientId), SR.FormatID4000(Parameters.ClientId)); + + // If a code_challenge was provided or if no authorization code is requested, the request is always + // considered valid, whether the proof key for code exchange requirement is enforced or not. + if (!string.IsNullOrEmpty(context.Request.CodeChallenge) || !context.Request.HasResponseType(ResponseTypes.Code)) + { + return; + } + + var application = await _applicationManager.FindByClientIdAsync(context.ClientId) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0032)); + + if (await _applicationManager.HasRequirementAsync(application, Requirements.Features.ProofKeyForCodeExchange)) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6240), Parameters.CodeChallenge); + + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2054(Parameters.CodeChallenge), + uri: SR.FormatID8000(SR.ID2054)); + + return; + } + } + } + + /// + /// Contains the logic responsible for rejecting pushed authorization requests that specify an identity + /// token hint that cannot be used by the client application sending the pushed authorization request. + /// + public sealed class ValidatePushedAuthorizedParty : IOpenIddictServerHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ValidatePushedProofKeyForCodeExchangeRequirement.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ValidatePushedAuthorizationRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (context.IdentityTokenHintPrincipal is null) + { + return default; + } + + Debug.Assert(!string.IsNullOrEmpty(context.ClientId), SR.FormatID4000(Parameters.ClientId)); + + // When an identity token hint is specified, the client_id (when present) must be + // listed either as a valid audience or as a presenter to be considered valid. + if (!context.IdentityTokenHintPrincipal.HasAudience(context.ClientId) && + !context.IdentityTokenHintPrincipal.HasPresenter(context.ClientId)) + { + context.Logger.LogWarning(SR.GetResourceString(SR.ID6263)); + + context.Reject( + error: Errors.InvalidRequest, + description: SR.GetResourceString(SR.ID2141), + uri: SR.FormatID8000(SR.ID2141)); + + return default; + } + + return default; + } + } + + /// + /// Contains the logic responsible for attaching the principal + /// extracted from the identity token hint to the event context. + /// + public sealed class AttachPushedPrincipal : IOpenIddictServerHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(int.MinValue + 100_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(HandlePushedAuthorizationRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + var notification = context.Transaction.GetProperty( + typeof(ValidatePushedAuthorizationRequestContext).FullName!) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0007)); + + context.IdentityTokenHintPrincipal ??= notification.IdentityTokenHintPrincipal; + + return default; + } + } } } diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Discovery.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Discovery.cs index c6cea248..8f0b8b4b 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Discovery.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Discovery.cs @@ -43,6 +43,7 @@ public static partial class OpenIddictServerHandlers AttachSubjectTypes.Descriptor, AttachPromptValues.Descriptor, AttachSigningAlgorithms.Descriptor, + AttachSecurityRequirements.Descriptor, AttachAdditionalMetadata.Descriptor, /* @@ -242,6 +243,7 @@ public static partial class OpenIddictServerHandlers [Metadata.RevocationEndpoint] = notification.RevocationEndpoint?.AbsoluteUri, [Metadata.UserInfoEndpoint] = notification.UserInfoEndpoint?.AbsoluteUri, [Metadata.DeviceAuthorizationEndpoint] = notification.DeviceAuthorizationEndpoint?.AbsoluteUri, + [Metadata.PushedAuthorizationRequestEndpoint] = notification.PushedAuthorizationEndpoint?.AbsoluteUri, [Metadata.JwksUri] = notification.JsonWebKeySetEndpoint?.AbsoluteUri, [Metadata.GrantTypesSupported] = notification.GrantTypes.ToArray(), [Metadata.ResponseTypesSupported] = notification.ResponseTypes.ToArray(), @@ -255,7 +257,9 @@ public static partial class OpenIddictServerHandlers [Metadata.TokenEndpointAuthMethodsSupported] = notification.TokenEndpointAuthenticationMethods.ToArray(), [Metadata.IntrospectionEndpointAuthMethodsSupported] = notification.IntrospectionEndpointAuthenticationMethods.ToArray(), [Metadata.RevocationEndpointAuthMethodsSupported] = notification.RevocationEndpointAuthenticationMethods.ToArray(), - [Metadata.DeviceAuthorizationEndpointAuthMethodsSupported] = notification.DeviceAuthorizationEndpointAuthenticationMethods.ToArray() + [Metadata.DeviceAuthorizationEndpointAuthMethodsSupported] = notification.DeviceAuthorizationEndpointAuthenticationMethods.ToArray(), + [Metadata.PushedAuthorizationRequestEndpointAuthMethodsSupported] = notification.PushedAuthorizationEndpointAuthenticationMethods.ToArray(), + [Metadata.RequirePushedAuthorizationRequests] = notification.RequirePushedAuthorizationRequests }; foreach (var metadata in notification.Metadata) @@ -373,17 +377,20 @@ public static partial class OpenIddictServerHandlers context.AuthorizationEndpoint ??= OpenIddictHelpers.CreateAbsoluteUri( context.BaseUri, context.Options.AuthorizationEndpointUris.FirstOrDefault()); - context.JsonWebKeySetEndpoint ??= OpenIddictHelpers.CreateAbsoluteUri( - context.BaseUri, context.Options.JsonWebKeySetEndpointUris.FirstOrDefault()); - context.DeviceAuthorizationEndpoint ??= OpenIddictHelpers.CreateAbsoluteUri( context.BaseUri, context.Options.DeviceAuthorizationEndpointUris.FirstOrDefault()); + context.EndSessionEndpoint ??= OpenIddictHelpers.CreateAbsoluteUri( + context.BaseUri, context.Options.EndSessionEndpointUris.FirstOrDefault()); + context.IntrospectionEndpoint ??= OpenIddictHelpers.CreateAbsoluteUri( context.BaseUri, context.Options.IntrospectionEndpointUris.FirstOrDefault()); - context.EndSessionEndpoint ??= OpenIddictHelpers.CreateAbsoluteUri( - context.BaseUri, context.Options.EndSessionEndpointUris.FirstOrDefault()); + context.JsonWebKeySetEndpoint ??= OpenIddictHelpers.CreateAbsoluteUri( + context.BaseUri, context.Options.JsonWebKeySetEndpointUris.FirstOrDefault()); + + context.PushedAuthorizationEndpoint ??= OpenIddictHelpers.CreateAbsoluteUri( + context.BaseUri, context.Options.PushedAuthorizationEndpointUris.FirstOrDefault()); context.RevocationEndpoint ??= OpenIddictHelpers.CreateAbsoluteUri( context.BaseUri, context.Options.RevocationEndpointUris.FirstOrDefault()); @@ -540,6 +547,13 @@ public static partial class OpenIddictServerHandlers context.IntrospectionEndpointAuthenticationMethods.UnionWith(context.Options.ClientAuthenticationMethods); } + // Note: "pushed_authorization_request_endpoint_auth_methods_supported" is not a standard parameter + // but is supported by OpenIddict 6.1.0 and higher for consistency with the other endpoints. + if (context.PushedAuthorizationEndpoint is not null) + { + context.PushedAuthorizationEndpointAuthenticationMethods.UnionWith(context.Options.ClientAuthenticationMethods); + } + if (context.RevocationEndpoint is not null) { context.RevocationEndpointAuthenticationMethods.UnionWith(context.Options.ClientAuthenticationMethods); @@ -770,6 +784,35 @@ public static partial class OpenIddictServerHandlers } } + /// + /// Contains the logic responsible for attaching the security requirements to the provider discovery document. + /// + public sealed class AttachSecurityRequirements : IOpenIddictServerHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(AttachSigningAlgorithms.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(HandleConfigurationRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + context.RequirePushedAuthorizationRequests = context.Options.RequirePushedAuthorizationRequests; + + return default; + } + } + /// /// Contains the logic responsible for attaching additional metadata to the provider discovery document. /// @@ -781,7 +824,7 @@ public static partial class OpenIddictServerHandlers public static OpenIddictServerHandlerDescriptor Descriptor { get; } = OpenIddictServerHandlerDescriptor.CreateBuilder() .UseSingletonHandler() - .SetOrder(AttachSigningAlgorithms.Descriptor.Order + 1_000) + .SetOrder(AttachSecurityRequirements.Descriptor.Order + 1_000) .SetType(OpenIddictServerHandlerType.BuiltIn) .Build(); diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Protection.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Protection.cs index c28299c0..eda2e55c 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Protection.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Protection.cs @@ -187,7 +187,7 @@ public static partial class OpenIddictServerHandlers 0 => null, // Otherwise, map the token types to their JWT public or internal representation. - _ => context.ValidTokenTypes.SelectMany(type =>type switch + _ => context.ValidTokenTypes.SelectMany(type => type switch { // For access tokens, both "at+jwt" and "application/at+jwt" are valid. TokenTypeHints.AccessToken => @@ -215,6 +215,10 @@ public static partial class OpenIddictServerHandlers // For user codes, only the short "oi_usrc+jwt" form is valid. TokenTypeHints.UserCode => [JsonWebTokenTypes.Private.UserCode], + // For user codes, only the short "oi_pshaurt+jwt" form is valid. + TokenTypeHints.Private.RequestToken + => [JsonWebTokenTypes.Private.RequestToken], + _ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0003)) }) }; @@ -543,6 +547,8 @@ public static partial class OpenIddictServerHandlers JsonWebTokenTypes.Private.RefreshToken => TokenTypeHints.RefreshToken, JsonWebTokenTypes.Private.UserCode => TokenTypeHints.UserCode, + JsonWebTokenTypes.Private.RequestToken => TokenTypeHints.Private.RequestToken, + _ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0003)) }); @@ -1449,7 +1455,8 @@ public static partial class OpenIddictServerHandlers // For authorization/device/user codes and refresh tokens, // attach claims destinations to the JWT claims collection. if (context.TokenType is TokenTypeHints.AuthorizationCode or TokenTypeHints.DeviceCode or - TokenTypeHints.RefreshToken or TokenTypeHints.UserCode) + TokenTypeHints.RefreshToken or TokenTypeHints.UserCode or + TokenTypeHints.Private.RequestToken) { var destinations = principal.GetDestinations(); if (destinations.Count is not 0) @@ -1478,6 +1485,8 @@ public static partial class OpenIddictServerHandlers TokenTypeHints.RefreshToken => JsonWebTokenTypes.Private.RefreshToken, TokenTypeHints.UserCode => JsonWebTokenTypes.Private.UserCode, + TokenTypeHints.Private.RequestToken => JsonWebTokenTypes.Private.RequestToken, + _ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0003)) } }; diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Session.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Session.cs index ca498b94..2d203228 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Session.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Session.cs @@ -8,6 +8,7 @@ using System.Collections.Immutable; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Security.Claims; +using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -28,13 +29,16 @@ public static partial class OpenIddictServerHandlers HandleEndSessionRequest.Descriptor, ApplyEndSessionResponse.Descriptor, ApplyEndSessionResponse.Descriptor, + ApplyEndSessionResponse.Descriptor, ApplyEndSessionResponse.Descriptor, /* * End-session request validation: */ + ValidateRequestUriParameter.Descriptor, ValidatePostLogoutRedirectUriParameter.Descriptor, ValidateAuthentication.Descriptor, + RestorePushedAuthorizationRequestParameters.Descriptor, ValidateClientPostLogoutRedirectUri.Descriptor, ValidateEndpointPermissions.Descriptor, ValidateAuthorizedParty.Descriptor, @@ -266,6 +270,47 @@ public static partial class OpenIddictServerHandlers } } + else if (context.Options.EnableEndSessionRequestCaching && + string.IsNullOrEmpty(context.Transaction.Request?.RequestUri)) + { + var @event = new ProcessSignInContext(context.Transaction) + { + Principal = new ClaimsPrincipal(new ClaimsIdentity()), + Response = new OpenIddictResponse() + }; + + if (notification.Parameters.Count > 0) + { + foreach (var parameter in notification.Parameters) + { + @event.Parameters.Add(parameter.Key, parameter.Value); + } + } + + await _dispatcher.DispatchAsync(@event); + + if (@event.IsRequestHandled) + { + context.HandleRequest(); + return; + } + + else if (@event.IsRequestSkipped) + { + context.SkipRequest(); + return; + } + + else if (@event.IsRejected) + { + context.Reject( + error: notification.Error ?? Errors.InvalidRequest, + description: notification.ErrorDescription, + uri: notification.ErrorUri); + return; + } + } + throw new InvalidOperationException(SR.GetResourceString(SR.ID0051)); } } @@ -318,6 +363,63 @@ public static partial class OpenIddictServerHandlers } } + /// + /// Contains the logic responsible for rejecting authorization requests that specify an invalid request_uri parameter. + /// + public sealed class ValidateRequestUriParameter : IOpenIddictServerHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(int.MinValue + 100_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ValidateEndSessionRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (string.IsNullOrEmpty(context.Request.RequestUri)) + { + return default; + } + + // OpenIddict only supports "request_uri" parameters containing a reference to a request token + // generated via the automatic request caching feature. Since OpenIddict uses a specific URN + // prefix for request tokens it generates, all the other values are automatically rejected. + if (!context.Request.RequestUri.StartsWith(RequestUris.Prefixes.Generic, StringComparison.Ordinal)) + { + context.Reject( + error: Errors.RequestUriNotSupported, + description: SR.FormatID2028(Parameters.RequestUri), + uri: SR.FormatID8000(SR.ID2028)); + + return default; + } + + // For consistency with authorization requests, the client_id parameter + // is also required when using a request_uri parameter is present. + if (string.IsNullOrEmpty(context.Request.ClientId)) + { + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2037(Parameters.RequestUri, Parameters.ClientId), + uri: SR.FormatID8000(SR.ID2037)); + + return default; + } + + return default; + } + } + /// /// Contains the logic responsible for rejecting end session requests that specify an invalid post_logout_redirect_uri parameter. /// @@ -431,8 +533,51 @@ public static partial class OpenIddictServerHandlers 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.IdentityTokenHintPrincipal = notification.IdentityTokenPrincipal; + context.RequestTokenPrincipal = notification.RequestTokenPrincipal; + } + } + + /// + /// Contains the logic responsible for restoring the parameters attached to the pushed authorization request. + /// + public sealed class RestorePushedAuthorizationRequestParameters : IOpenIddictServerHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ValidateAuthentication.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ValidateEndSessionRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + var value = context.RequestTokenPrincipal?.GetClaim(Claims.Private.RequestParameters); + if (string.IsNullOrEmpty(value)) + { + return default; + } + + using var document = JsonDocument.Parse(value); + var request = new OpenIddictRequest(document.RootElement.Clone()) + { + RequestUri = context.Request.RequestUri + }; + + context.Request = request; + context.PostLogoutRedirectUri = request.PostLogoutRedirectUri; + + return default; } } @@ -458,7 +603,7 @@ public static partial class OpenIddictServerHandlers .AddFilter() .AddFilter() .UseScopedHandler() - .SetOrder(ValidateAuthentication.Descriptor.Order + 1_000) + .SetOrder(RestorePushedAuthorizationRequestParameters.Descriptor.Order + 1_000) .SetType(OpenIddictServerHandlerType.BuiltIn) .Build(); @@ -838,7 +983,9 @@ public static partial class OpenIddictServerHandlers throw new ArgumentNullException(nameof(context)); } - if (context.Request is null) + // If the end session response contains a request token, do not use the + // post_logout_redirect_uri, as the user agent will be redirected to the same page. + if (context.Request is null || !string.IsNullOrEmpty(context.Response.RequestUri)) { return default; } @@ -880,8 +1027,11 @@ public static partial class OpenIddictServerHandlers throw new ArgumentNullException(nameof(context)); } - // Attach the request state to the end session response. - if (string.IsNullOrEmpty(context.Response.State)) + // If the user agent is expected to be redirected to the client application, attach the request + // state to the end session response to help the client mitigate CSRF/session fixation attacks. + // + // Note: don't override the state if one was already attached to the response instance. + if (!string.IsNullOrEmpty(context.PostLogoutRedirectUri) && string.IsNullOrEmpty(context.Response.State)) { context.Response.State = context.Request?.State; } diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.cs index 27fecf6e..170c1bb2 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.cs @@ -43,6 +43,8 @@ public static partial class OpenIddictServerHandlers ValidateClientAssertionWellknownClaims.Descriptor, ValidateClientAssertionIssuer.Descriptor, ValidateClientAssertionAudience.Descriptor, + ValidateRequestToken.Descriptor, + ValidateRequestTokenType.Descriptor, ValidateAccessToken.Descriptor, ValidateAuthorizationCode.Descriptor, ValidateDeviceCode.Descriptor, @@ -50,6 +52,7 @@ public static partial class OpenIddictServerHandlers ValidateIdentityToken.Descriptor, ValidateRefreshToken.Descriptor, ValidateUserCode.Descriptor, + ResolveHostAuthenticationProperties.Descriptor, ReformatValidatedTokens.Descriptor, @@ -78,6 +81,7 @@ public static partial class OpenIddictServerHandlers PrepareAccessTokenPrincipal.Descriptor, PrepareAuthorizationCodePrincipal.Descriptor, PrepareDeviceCodePrincipal.Descriptor, + PrepareRequestTokenPrincipal.Descriptor, PrepareRefreshTokenPrincipal.Descriptor, PrepareIdentityTokenPrincipal.Descriptor, PrepareUserCodePrincipal.Descriptor, @@ -85,6 +89,7 @@ public static partial class OpenIddictServerHandlers GenerateAccessToken.Descriptor, GenerateAuthorizationCode.Descriptor, GenerateDeviceCode.Descriptor, + GenerateRequestToken.Descriptor, GenerateRefreshToken.Descriptor, AttachDeviceCodeIdentifier.Descriptor, @@ -103,6 +108,7 @@ public static partial class OpenIddictServerHandlers * Sign-out processing: */ ValidateSignOutDemand.Descriptor, + RedeemLogoutTokenEntry.Descriptor, AttachCustomSignOutParameters.Descriptor, /* @@ -158,6 +164,7 @@ public static partial class OpenIddictServerHandlers Matches(context.Options.EndUserVerificationEndpointUris) ? OpenIddictServerEndpointType.EndUserVerification : Matches(context.Options.IntrospectionEndpointUris) ? OpenIddictServerEndpointType.Introspection : Matches(context.Options.JsonWebKeySetEndpointUris) ? OpenIddictServerEndpointType.JsonWebKeySet : + Matches(context.Options.PushedAuthorizationEndpointUris) ? OpenIddictServerEndpointType.PushedAuthorization : Matches(context.Options.RevocationEndpointUris) ? OpenIddictServerEndpointType.Revocation : Matches(context.Options.TokenEndpointUris) ? OpenIddictServerEndpointType.Token : Matches(context.Options.UserInfoEndpointUris) ? OpenIddictServerEndpointType.UserInfo : @@ -242,8 +249,9 @@ public static partial class OpenIddictServerHandlers { OpenIddictServerEndpointType.Authorization or OpenIddictServerEndpointType.DeviceAuthorization or OpenIddictServerEndpointType.EndSession or OpenIddictServerEndpointType.EndUserVerification or - OpenIddictServerEndpointType.Introspection or OpenIddictServerEndpointType.Revocation or - OpenIddictServerEndpointType.Token or OpenIddictServerEndpointType.UserInfo + OpenIddictServerEndpointType.Introspection or OpenIddictServerEndpointType.PushedAuthorization or + OpenIddictServerEndpointType.Revocation or OpenIddictServerEndpointType.Token or + OpenIddictServerEndpointType.UserInfo => default, _ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0002)), @@ -305,8 +313,8 @@ public static partial class OpenIddictServerHandlers // Client assertions can be used with all the endpoints that support client authentication. // By default, client assertions are not required, but they are extracted and validated if // present and invalid client assertions are always automatically rejected by OpenIddict. - OpenIddictServerEndpointType.DeviceAuthorization or OpenIddictServerEndpointType.Introspection or - OpenIddictServerEndpointType.Revocation or OpenIddictServerEndpointType.Token + OpenIddictServerEndpointType.DeviceAuthorization or OpenIddictServerEndpointType.Introspection or + OpenIddictServerEndpointType.Revocation or OpenIddictServerEndpointType.Token => (true, false, true, true), _ => (false, false, false, false) @@ -342,17 +350,31 @@ public static partial class OpenIddictServerHandlers context.ValidateIdentityToken, context.RejectIdentityToken) = context.EndpointType switch { - // The identity token received by the authorization and logout - // endpoints are not required and serve as optional hints. + // The identity token received by the authorization, end session and pushed + // authorization endpoints are not required and serve as optional hints. // // As such, identity token hints are extracted and validated, but // the authentication demand is not rejected if they are not valid. - OpenIddictServerEndpointType.Authorization or OpenIddictServerEndpointType.EndSession + OpenIddictServerEndpointType.Authorization or + OpenIddictServerEndpointType.EndSession or + OpenIddictServerEndpointType.PushedAuthorization => (true, false, true, false), _ => (false, false, false, false) }; + (context.ExtractRequestToken, + context.RequireRequestToken, + context.ValidateRequestToken, + context.RejectRequestToken) = context.EndpointType switch + { + // Always validate request tokens received by the authorization or end session endpoints. + OpenIddictServerEndpointType.Authorization or + OpenIddictServerEndpointType.EndSession => (true, false, true, true), + + _ => (false, false, false, false) + }; + (context.ExtractRefreshToken, context.RequireRefreshToken, context.ValidateRefreshToken, @@ -450,12 +472,30 @@ public static partial class OpenIddictServerHandlers context.IdentityToken = context.EndpointType switch { OpenIddictServerEndpointType.Authorization or - OpenIddictServerEndpointType.EndSession when context.ExtractIdentityToken + OpenIddictServerEndpointType.EndSession or + OpenIddictServerEndpointType.PushedAuthorization when context.ExtractIdentityToken => context.Request.IdTokenHint, _ => null }; + context.RequestToken = context.EndpointType switch + { + OpenIddictServerEndpointType.Authorization when + context.ExtractRequestToken && + context.Request.RequestUri is { Length: > 0 } uri && + uri.StartsWith(RequestUris.Prefixes.Generic, StringComparison.OrdinalIgnoreCase) + => uri[RequestUris.Prefixes.Generic.Length..], + + OpenIddictServerEndpointType.EndSession when + context.ExtractRequestToken && + context.Request.RequestUri is { Length: > 0 } uri && + uri.StartsWith(RequestUris.Prefixes.Generic, StringComparison.OrdinalIgnoreCase) + => uri[RequestUris.Prefixes.Generic.Length..], + + _ => null + }; + context.RefreshToken = context.EndpointType switch { OpenIddictServerEndpointType.Token when context.ExtractRefreshToken @@ -508,6 +548,7 @@ public static partial class OpenIddictServerHandlers (context.RequireGenericToken && string.IsNullOrEmpty(context.GenericToken)) || (context.RequireIdentityToken && string.IsNullOrEmpty(context.IdentityToken)) || (context.RequireRefreshToken && string.IsNullOrEmpty(context.RefreshToken)) || + (context.RequireRequestToken && string.IsNullOrEmpty(context.RequestToken)) || (context.RequireUserCode && string.IsNullOrEmpty(context.UserCode))) { context.Reject( @@ -900,6 +941,14 @@ public static partial class OpenIddictServerHandlers return true; } + // If the current request is a pushed authorization request, consider the audience valid + // if the address matches one of the URIs assigned to the pushed authorization endpoint. + else if (context.EndpointType is OpenIddictServerEndpointType.PushedAuthorization && + MatchesAnyUri(uri, context.Options.PushedAuthorizationEndpointUris)) + { + return true; + } + // If the current request is a revocation request, consider the audience valid // if the address matches one of the URIs assigned to the revocation endpoint. else if (context.EndpointType is OpenIddictServerEndpointType.Revocation && @@ -1037,8 +1086,8 @@ public static partial class OpenIddictServerHandlers error: context.EndpointType switch { // For non-interactive endpoints, return "invalid_client" instead of "invalid_request". - OpenIddictServerEndpointType.DeviceAuthorization or OpenIddictServerEndpointType.Introspection or - OpenIddictServerEndpointType.Revocation or OpenIddictServerEndpointType.Token + OpenIddictServerEndpointType.DeviceAuthorization or OpenIddictServerEndpointType.Introspection or + OpenIddictServerEndpointType.Revocation or OpenIddictServerEndpointType.Token => Errors.InvalidClient, _ => Errors.InvalidRequest @@ -1092,6 +1141,7 @@ public static partial class OpenIddictServerHandlers if (context.EndpointType is OpenIddictServerEndpointType.Authorization or OpenIddictServerEndpointType.EndSession or OpenIddictServerEndpointType.EndUserVerification or + OpenIddictServerEndpointType.PushedAuthorization or OpenIddictServerEndpointType.UserInfo) { return; @@ -1201,6 +1251,7 @@ public static partial class OpenIddictServerHandlers if (context.EndpointType is OpenIddictServerEndpointType.Authorization or OpenIddictServerEndpointType.EndSession or OpenIddictServerEndpointType.EndUserVerification or + OpenIddictServerEndpointType.PushedAuthorization or OpenIddictServerEndpointType.UserInfo) { return; @@ -1229,6 +1280,130 @@ public static partial class OpenIddictServerHandlers } } + /// + /// Contains the logic responsible for validating the request token resolved from the context. + /// + public sealed class ValidateRequestToken : IOpenIddictServerHandler + { + private readonly IOpenIddictServerDispatcher _dispatcher; + + public ValidateRequestToken(IOpenIddictServerDispatcher dispatcher) + => _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseScopedHandler() + .SetOrder(ValidateClientSecret.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public async ValueTask HandleAsync(ProcessAuthenticationContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (string.IsNullOrEmpty(context.RequestToken)) + { + return; + } + + var notification = new ValidateTokenContext(context.Transaction) + { + Token = context.RequestToken, + ValidTokenTypes = { TokenTypeHints.Private.RequestToken } + }; + + await _dispatcher.DispatchAsync(notification); + + if (notification.IsRequestHandled) + { + context.HandleRequest(); + return; + } + + else if (notification.IsRequestSkipped) + { + context.SkipRequest(); + return; + } + + else if (notification.IsRejected) + { + if (context.RejectRequestToken) + { + context.Reject( + error: notification.Error ?? Errors.InvalidRequest, + description: notification.ErrorDescription, + uri: notification.ErrorUri); + return; + } + + return; + } + + context.RequestTokenPrincipal = notification.Principal; + } + } + + /// + /// Contains the logic responsible for ensuring the resolved request + /// token is suitable for the requested authentication demand. + /// + public sealed class ValidateRequestTokenType : IOpenIddictServerHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() + .AddFilter() + .UseSingletonHandler() + .SetOrder(ValidateRequestToken.Descriptor.Order + 1_000) + .Build(); + + /// + public ValueTask HandleAsync(ProcessAuthenticationContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(context.RequestTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); + + // Reject the authentication demand if the request token is not expected to be + // received by the current endpoint as it may indicate a mix-up attack (e.g a request + // token created for an end session request was used for an authorization request). + switch ((context.EndpointType, context.RequestTokenPrincipal.GetClaim(Claims.Private.RequestTokenType))) + { + case (OpenIddictServerEndpointType.Authorization, not ( + RequestTokenTypes.Private.CachedAuthorizationRequest or + RequestTokenTypes.Private.PushedAuthorizationRequest)): + + case (OpenIddictServerEndpointType.EndSession, not RequestTokenTypes.Private.CachedEndSessionRequest): + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2182(Parameters.RequestUri), + uri: SR.FormatID8000(SR.ID2182)); + + return default; + + // For other endpoints that don't natively support request tokens, don't return an error + // to allow custom implementations to use request tokens with other types of endpoints. + } + + return default; + } + } + /// /// Contains the logic responsible for validating the access token resolved from the context. /// @@ -1246,7 +1421,7 @@ public static partial class OpenIddictServerHandlers = OpenIddictServerHandlerDescriptor.CreateBuilder() .AddFilter() .UseScopedHandler() - .SetOrder(ValidateClientSecret.Descriptor.Order + 1_000) + .SetOrder(ValidateRequestTokenType.Descriptor.Order + 1_000) .SetType(OpenIddictServerHandlerType.BuiltIn) .Build(); @@ -1573,7 +1748,8 @@ public static partial class OpenIddictServerHandlers { // Don't validate the lifetime of id_tokens used as id_token_hints. DisableLifetimeValidation = context.EndpointType is OpenIddictServerEndpointType.Authorization or - OpenIddictServerEndpointType.EndSession, + OpenIddictServerEndpointType.EndSession or + OpenIddictServerEndpointType.PushedAuthorization, Token = context.IdentityToken, ValidTokenTypes = { TokenTypeHints.IdToken } }; @@ -1906,6 +2082,7 @@ public static partial class OpenIddictServerHandlers if (context.EndpointType is not (OpenIddictServerEndpointType.Authorization or OpenIddictServerEndpointType.EndUserVerification or + OpenIddictServerEndpointType.PushedAuthorization or OpenIddictServerEndpointType.Token or OpenIddictServerEndpointType.UserInfo)) { @@ -1941,7 +2118,9 @@ public static partial class OpenIddictServerHandlers context.Response.Error ??= context.EndpointType switch { - OpenIddictServerEndpointType.Authorization or OpenIddictServerEndpointType.EndUserVerification + OpenIddictServerEndpointType.Authorization or + OpenIddictServerEndpointType.EndUserVerification or + OpenIddictServerEndpointType.PushedAuthorization => Errors.AccessDenied, OpenIddictServerEndpointType.Token => Errors.InvalidGrant, @@ -1952,7 +2131,9 @@ public static partial class OpenIddictServerHandlers context.Response.ErrorDescription ??= context.EndpointType switch { - OpenIddictServerEndpointType.Authorization or OpenIddictServerEndpointType.EndUserVerification + OpenIddictServerEndpointType.Authorization or + OpenIddictServerEndpointType.EndUserVerification or + OpenIddictServerEndpointType.PushedAuthorization => SR.GetResourceString(SR.ID2015), OpenIddictServerEndpointType.Token => SR.GetResourceString(SR.ID2024), @@ -1963,7 +2144,9 @@ public static partial class OpenIddictServerHandlers context.Response.ErrorUri ??= context.EndpointType switch { - OpenIddictServerEndpointType.Authorization or OpenIddictServerEndpointType.EndUserVerification + OpenIddictServerEndpointType.Authorization or + OpenIddictServerEndpointType.EndUserVerification or + OpenIddictServerEndpointType.PushedAuthorization => SR.FormatID8000(SR.ID2015), OpenIddictServerEndpointType.Token => SR.FormatID8000(SR.ID2024), @@ -2154,46 +2337,62 @@ public static partial class OpenIddictServerHandlers throw new ArgumentNullException(nameof(context)); } - if (context.EndpointType is not (OpenIddictServerEndpointType.Authorization or - OpenIddictServerEndpointType.DeviceAuthorization or - OpenIddictServerEndpointType.EndUserVerification or - OpenIddictServerEndpointType.Token)) + switch (context.EndpointType) { - throw new InvalidOperationException(SR.GetResourceString(SR.ID0010)); - } + // Note: sign-in operations triggered from the device authorization or pushed authorization endpoints + // can't be associated to specific users as users' identity is not known until they reach the end-user + // verification endpoint and validate the user code (for the device authorization flow) or are redirected + // to the authorization endpoint and approve the demand (for an interactive flow like the code flow). + // + // As such, the principal used in this case cannot contain an authenticated identity or a subject claim. + case OpenIddictServerEndpointType.DeviceAuthorization: + case OpenIddictServerEndpointType.PushedAuthorization: + + // Similarly, sign-in operations triggered from the authorization or end session endpoints + // when the built-in request caching (that stores requests as request tokens in the database) + // is enabled cannot be associated to a specific user or contain an authenticated identity. + case OpenIddictServerEndpointType.Authorization + when context.Options.EnableAuthorizationRequestCaching && + string.IsNullOrEmpty(context.Request.RequestUri): + case OpenIddictServerEndpointType.EndSession + when context.Options.EnableEndSessionRequestCaching && + string.IsNullOrEmpty(context.Request.RequestUri): + if (context.Principal is not { Identity: ClaimsIdentity }) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0011)); + } - if (context.Principal is not { Identity: ClaimsIdentity }) - { - throw new InvalidOperationException(SR.GetResourceString(SR.ID0011)); - } + if (context.Principal.Identity.IsAuthenticated) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0012)); + } - // Note: sign-in operations triggered from the device authorization endpoint can't be associated to specific users - // as users' identity is not known until they reach the end-user verification endpoint and validate the user code. - // As such, the principal used in this case cannot contain an authenticated identity or a subject claim. - if (context.EndpointType is OpenIddictServerEndpointType.DeviceAuthorization) - { - if (context.Principal.Identity.IsAuthenticated) - { - throw new InvalidOperationException(SR.GetResourceString(SR.ID0012)); - } + if (context.Principal.HasClaim(Claims.Subject)) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0013)); + } + break; - if (context.Principal.HasClaim(Claims.Subject)) - { - throw new InvalidOperationException(SR.GetResourceString(SR.ID0013)); - } - } + case OpenIddictServerEndpointType.Authorization: + case OpenIddictServerEndpointType.EndUserVerification: + case OpenIddictServerEndpointType.Token: + if (context.Principal is not { Identity: ClaimsIdentity }) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0011)); + } - else - { - if (!context.Principal.Identity.IsAuthenticated) - { - throw new InvalidOperationException(SR.GetResourceString(SR.ID0014)); - } + if (!context.Principal.Identity.IsAuthenticated) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0014)); + } - if (string.IsNullOrEmpty(context.Principal.GetClaim(Claims.Subject))) - { - throw new InvalidOperationException(SR.GetResourceString(SR.ID0015)); - } + if (string.IsNullOrEmpty(context.Principal.GetClaim(Claims.Subject))) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0015)); + } + break; + + default: throw new InvalidOperationException(SR.GetResourceString(SR.ID0010)); } foreach (var group in context.Principal.Claims @@ -2238,7 +2437,8 @@ public static partial class OpenIddictServerHandlers // The following claims MUST be represented as unique integers. Claims.Private.AccessTokenLifetime or Claims.Private.AuthorizationCodeLifetime or Claims.Private.DeviceCodeLifetime or Claims.Private.IdentityTokenLifetime or - Claims.Private.RefreshTokenLifetime or Claims.Private.RefreshTokenLifetime + Claims.Private.RefreshTokenLifetime or Claims.Private.RefreshTokenLifetime or + Claims.Private.RequestTokenLifetime => values is [{ ValueType: ClaimValueTypes.Integer or ClaimValueTypes.Integer32 or ClaimValueTypes.Integer64 or ClaimValueTypes.UInteger32 or ClaimValueTypes.UInteger64 }], @@ -2256,8 +2456,7 @@ public static partial class OpenIddictServerHandlers } /// - /// Contains the logic responsible for redeeming the token entry corresponding to - /// the received authorization code, device code, user code or refresh token. + /// Contains the logic responsible for redeeming the token entry corresponding to the received token. /// Note: this handler is not used when the degraded mode is enabled. /// public sealed class RedeemTokenEntry : IOpenIddictServerHandler @@ -2294,6 +2493,7 @@ public static partial class OpenIddictServerHandlers switch (context.EndpointType) { + case OpenIddictServerEndpointType.Authorization: case OpenIddictServerEndpointType.EndUserVerification: case OpenIddictServerEndpointType.Token when context.Request.IsAuthorizationCodeGrantType(): case OpenIddictServerEndpointType.Token when context.Request.IsDeviceCodeGrantType(): @@ -2310,6 +2510,8 @@ public static partial class OpenIddictServerHandlers var principal = context.EndpointType switch { + OpenIddictServerEndpointType.Authorization => notification.RequestTokenPrincipal, + OpenIddictServerEndpointType.Token when context.Request.IsAuthorizationCodeGrantType() => notification.AuthorizationCodePrincipal, @@ -2324,7 +2526,10 @@ public static partial class OpenIddictServerHandlers _ => null }; - Debug.Assert(principal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); + if (principal is null) + { + return; + } // Extract the token identifier from the authentication principal. // If no token identifier can be found, this indicates that the token has no backing database entry. @@ -2632,6 +2837,13 @@ public static partial class OpenIddictServerHandlers (context.GenerateAccessToken, context.IncludeAccessToken) = context.EndpointType switch { + // Never generate an access token if request caching was enabled and the authorization + // request doesn't already contain a request_uri parameter, as the user agent will be + // redirected to the authorization endpoint after generating a request token. + OpenIddictServerEndpointType.Authorization when + context.Options.EnableAuthorizationRequestCaching && + string.IsNullOrEmpty(context.Request.RequestUri) => (false, false), + // For authorization requests, generate and return an access token // if a response type containing the "token" value was specified. OpenIddictServerEndpointType.Authorization when context.Request.HasResponseType(ResponseTypes.Token) @@ -2645,6 +2857,13 @@ public static partial class OpenIddictServerHandlers (context.GenerateAuthorizationCode, context.IncludeAuthorizationCode) = context.EndpointType switch { + // Never generate an authorization code if request caching was enabled and the authorization + // request doesn't already contain a request_uri parameter, as the user agent will be + // redirected to the authorization endpoint after generating a request token. + OpenIddictServerEndpointType.Authorization when + context.Options.EnableAuthorizationRequestCaching && + string.IsNullOrEmpty(context.Request.RequestUri) => (false, false), + // For authorization requests, generate and return an authorization code // if a response type containing the "code" value was specified. OpenIddictServerEndpointType.Authorization when context.Request.HasResponseType(ResponseTypes.Code) @@ -2669,6 +2888,13 @@ public static partial class OpenIddictServerHandlers (context.GenerateIdentityToken, context.IncludeIdentityToken) = context.EndpointType switch { + // Never generate an identity token if request caching was enabled and the authorization + // request doesn't contain a request_uri parameter, as the user agent will be + // redirected to the authorization endpoint after generating a request token. + OpenIddictServerEndpointType.Authorization when + context.Options.EnableAuthorizationRequestCaching && + string.IsNullOrEmpty(context.Request.RequestUri) => (false, false), + // For authorization requests, generate and return an identity token if a response type // containing code was specified and if the openid scope was explicitly or implicitly granted. OpenIddictServerEndpointType.Authorization when @@ -2681,6 +2907,26 @@ public static partial class OpenIddictServerHandlers _ => (false, false) }; + (context.GenerateRequestToken, context.IncludeRequestToken) = context.EndpointType switch + { + // Always generate a request token if request caching was enabled and the + // authorization request doesn't already contain a request_uri parameter. + OpenIddictServerEndpointType.Authorization when + context.Options.EnableAuthorizationRequestCaching && + string.IsNullOrEmpty(context.Request.RequestUri) => (true, true), + + // Always generate a request token if request caching was enabled and the + // end session request doesn't already contain a request_uri parameter. + OpenIddictServerEndpointType.EndSession when + context.Options.EnableEndSessionRequestCaching && + string.IsNullOrEmpty(context.Request.RequestUri) => (true, true), + + // Always generate and return a request token if the request is a PAR request. + OpenIddictServerEndpointType.PushedAuthorization => (true, true), + + _ => (false, false) + }; + (context.GenerateRefreshToken, context.IncludeRefreshToken) = context.EndpointType switch { // For token requests, allow a refresh token to be returned @@ -3210,6 +3456,138 @@ public static partial class OpenIddictServerHandlers } } + /// + /// Contains the logic responsible for preparing and attaching the claims principal used + /// to generate the request token, if one is going to be returned. + /// + public sealed class PrepareRequestTokenPrincipal : IOpenIddictServerHandler + { + private readonly IOpenIddictApplicationManager? _applicationManager; + + public PrepareRequestTokenPrincipal(IOpenIddictApplicationManager? applicationManager = null) + => _applicationManager = applicationManager; + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseScopedHandler(static provider => + { + // Note: the application manager is only resolved if the degraded mode was not enabled to ensure + // invalid core configuration exceptions are not thrown even if the managers were registered. + var options = provider.GetRequiredService>().CurrentValue; + + return options.EnableDegradedMode ? + new PrepareRequestTokenPrincipal() : + new PrepareRequestTokenPrincipal(provider.GetService() ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0016))); + }) + .SetOrder(PrepareDeviceCodePrincipal.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public async ValueTask HandleAsync(ProcessSignInContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(context.Principal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); + + // Create a new principal containing only the filtered claims. + // Actors identities are also filtered (delegation scenarios). + var principal = context.Principal.Clone(claim => + { + // Never include the public or internal token identifiers to ensure the identifiers + // that are automatically inherited from the parent token are not reused for the new token. + if (string.Equals(claim.Type, Claims.JwtId, StringComparison.OrdinalIgnoreCase) || + string.Equals(claim.Type, Claims.Private.TokenId, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // Never include the creation and expiration dates that are automatically + // inherited from the parent token are not reused for the new token. + if (string.Equals(claim.Type, Claims.ExpiresAt, StringComparison.OrdinalIgnoreCase) || + string.Equals(claim.Type, Claims.IssuedAt, StringComparison.OrdinalIgnoreCase) || + string.Equals(claim.Type, Claims.NotBefore, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // Other claims are always included in the device code, even private claims. + return true; + }); + + principal.SetCreationDate( +#if SUPPORTS_TIME_PROVIDER + context.Options.TimeProvider?.GetUtcNow() ?? +#endif + DateTimeOffset.UtcNow); + + // If a specific token lifetime was attached to the principal, prefer it over any other value. + var lifetime = context.Principal.GetRequestTokenLifetime(); + + // If the client to which the token is returned is known, use the attached setting if available. + if (lifetime is null && !context.Options.EnableDegradedMode && !string.IsNullOrEmpty(context.ClientId)) + { + if (_applicationManager is null) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0016)); + } + + var application = await _applicationManager.FindByClientIdAsync(context.ClientId) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0017)); + + var settings = await _applicationManager.GetSettingsAsync(application); + if (settings.TryGetValue(Settings.TokenLifetimes.RequestToken, out string? setting) && + TimeSpan.TryParse(setting, CultureInfo.InvariantCulture, out var value)) + { + lifetime = value; + } + } + + // Otherwise, fall back to the global value. + lifetime ??= context.Options.RequestTokenLifetime; + + if (lifetime.HasValue) + { + principal.SetExpirationDate(principal.GetCreationDate() + lifetime.Value); + } + + // Use the server identity as the token issuer. + principal.SetClaim(Claims.Private.Issuer, (context.Options.Issuer ?? context.BaseUri)?.AbsoluteUri); + + // Store the type of the request token. + principal.SetClaim(Claims.Private.RequestTokenType, context.EndpointType switch + { + OpenIddictServerEndpointType.Authorization => RequestTokenTypes.Private.CachedAuthorizationRequest, + OpenIddictServerEndpointType.EndSession => RequestTokenTypes.Private.CachedEndSessionRequest, + OpenIddictServerEndpointType.PushedAuthorization => RequestTokenTypes.Private.PushedAuthorizationRequest, + + _ => null + }); + + // Store the request parameters as a special JSON object claim. + // + // Note: parameters used for client authentication are deliberately filtered out. + var parameters = from parameter in context.Request.GetParameters() + where parameter.Key is not (Parameters.ClientAssertion or + Parameters.ClientAssertionType or + Parameters.ClientSecret) + select parameter; + + principal.SetClaim(Claims.Private.RequestParameters, JsonSerializer.Deserialize( + JsonSerializer.Serialize(new OpenIddictRequest(parameters)))); + + context.RequestTokenPrincipal = principal; + } + } + /// /// Contains the logic responsible for preparing and attaching the claims principal /// used to generate the refresh token, if one is going to be returned. @@ -3238,7 +3616,7 @@ public static partial class OpenIddictServerHandlers new PrepareRefreshTokenPrincipal(provider.GetService() ?? throw new InvalidOperationException(SR.GetResourceString(SR.ID0016))); }) - .SetOrder(PrepareDeviceCodePrincipal.Descriptor.Order + 1_000) + .SetOrder(PrepareRequestTokenPrincipal.Descriptor.Order + 1_000) .SetType(OpenIddictServerHandlerType.BuiltIn) .Build(); @@ -3484,8 +3862,10 @@ public static partial class OpenIddictServerHandlers // See http://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation for more information. principal.SetClaim(Claims.Nonce, context.EndpointType switch { - OpenIddictServerEndpointType.Authorization => context.Request.Nonce, - OpenIddictServerEndpointType.Token => context.Principal.GetClaim(Claims.Private.Nonce), + OpenIddictServerEndpointType.Authorization or + OpenIddictServerEndpointType.PushedAuthorization => context.Request.Nonce, + + OpenIddictServerEndpointType.Token => context.Principal.GetClaim(Claims.Private.Nonce), _ => null }); @@ -3794,7 +4174,7 @@ public static partial class OpenIddictServerHandlers }, Principal = context.DeviceCodePrincipal!, TokenFormat = TokenFormats.Jwt, - TokenType = TokenTypeHints.DeviceCode, + TokenType = TokenTypeHints.DeviceCode }; await _dispatcher.DispatchAsync(notification); @@ -3824,6 +4204,73 @@ public static partial class OpenIddictServerHandlers } } + /// + /// Contains the logic responsible for generating a request token for the current sign-in operation. + /// + public sealed class GenerateRequestToken : IOpenIddictServerHandler + { + private readonly IOpenIddictServerDispatcher _dispatcher; + + public GenerateRequestToken(IOpenIddictServerDispatcher dispatcher) + => _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseScopedHandler() + .SetOrder(GenerateDeviceCode.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public async ValueTask HandleAsync(ProcessSignInContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + var notification = new GenerateTokenContext(context.Transaction) + { + ClientId = context.ClientId, + CreateTokenEntry = !context.Options.DisableTokenStorage, + PersistTokenPayload = !context.Options.DisableTokenStorage, + IsReferenceToken = !context.Options.DisableTokenStorage, + Principal = context.RequestTokenPrincipal!, + TokenFormat = TokenFormats.Jwt, + TokenType = TokenTypeHints.Private.RequestToken + }; + + 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.RequestToken = notification.Token; + } + } + /// /// Contains the logic responsible for generating a refresh token for the current sign-in operation. /// @@ -3841,7 +4288,7 @@ public static partial class OpenIddictServerHandlers = OpenIddictServerHandlerDescriptor.CreateBuilder() .AddFilter() .UseScopedHandler() - .SetOrder(GenerateDeviceCode.Descriptor.Order + 1_000) + .SetOrder(GenerateRequestToken.Descriptor.Order + 1_000) .SetType(OpenIddictServerHandlerType.BuiltIn) .Build(); @@ -4368,6 +4815,17 @@ public static partial class OpenIddictServerHandlers context.Response.IdToken = context.IdentityToken; } + if (context.IncludeRequestToken) + { + if (context.EndpointType is OpenIddictServerEndpointType.Authorization or + OpenIddictServerEndpointType.EndSession) + { + context.Response[Parameters.ClientId] = context.Request.ClientId; + } + + context.Response.RequestUri = RequestUris.Prefixes.Generic + context.RequestToken; + } + if (context.IncludeRefreshToken) { context.Response.RefreshToken = context.RefreshToken; @@ -4417,6 +4875,29 @@ public static partial class OpenIddictServerHandlers }; } + else if (context.EndpointType is OpenIddictServerEndpointType.PushedAuthorization) + { + context.Response.ExpiresIn = context.RequestTokenPrincipal?.GetExpirationDate() switch + { + // If an expiration date was set on the pushed authorization + // request token principal, return it to the client application. + DateTimeOffset date when date > ( +#if SUPPORTS_TIME_PROVIDER + context.Options.TimeProvider?.GetUtcNow() ?? +#endif + DateTimeOffset.UtcNow) + => (long) ((date - ( +#if SUPPORTS_TIME_PROVIDER + context.Options.TimeProvider?.GetUtcNow() ?? +#endif + DateTimeOffset.UtcNow)).TotalSeconds + .5), + + // Otherwise, return an arbitrary value, as the "expires_in" + // parameter is required in pushed authorization responses. + _ => 5 * 60 // 5 minutes, in seconds. + }; + } + return default; } } @@ -4490,6 +4971,72 @@ public static partial class OpenIddictServerHandlers } } + /// + /// Contains the logic responsible for redeeming the token entry corresponding to the received token. + /// Note: this handler is not used when the degraded mode is enabled. + /// + public sealed class RedeemLogoutTokenEntry : IOpenIddictServerHandler + { + private readonly IOpenIddictTokenManager _tokenManager; + + public RedeemLogoutTokenEntry() => throw new InvalidOperationException(SR.GetResourceString(SR.ID0016)); + + public RedeemLogoutTokenEntry(IOpenIddictTokenManager tokenManager) + => _tokenManager = tokenManager ?? throw new ArgumentNullException(nameof(tokenManager)); + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() + .AddFilter() + .AddFilter() + .UseScopedHandler() + // Note: this handler is deliberately executed early in the pipeline to ensure + // that the token database entry is always marked as redeemed even if the sign-out + // demand is rejected later in the pipeline (e.g because an error was returned). + .SetOrder(ValidateSignOutDemand.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public async ValueTask HandleAsync(ProcessSignOutContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + var notification = context.Transaction.GetProperty( + typeof(ProcessAuthenticationContext).FullName!) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0007)); + + var principal = notification.RequestTokenPrincipal; + if (principal is null) + { + return; + } + + // Extract the token identifier from the authentication principal. + // If no token identifier can be found, this indicates that the token has no backing database entry. + var identifier = principal.GetTokenId(); + if (string.IsNullOrEmpty(identifier)) + { + return; + } + + var token = await _tokenManager.FindByIdAsync(identifier); + if (token is null) + { + return; + } + + // Mark the token as redeemed to prevent future reuses. + await _tokenManager.TryRedeemAsync(token); + } + } + /// /// Contains the logic responsible for attaching the parameters /// populated from user-defined handlers to the sign-out response. diff --git a/src/OpenIddict.Server/OpenIddictServerOptions.cs b/src/OpenIddict.Server/OpenIddictServerOptions.cs index 0077fb54..340584b5 100644 --- a/src/OpenIddict.Server/OpenIddictServerOptions.cs +++ b/src/OpenIddict.Server/OpenIddictServerOptions.cs @@ -94,6 +94,11 @@ public sealed class OpenIddictServerOptions new Uri(".well-known/jwks", UriKind.Relative) ]; + /// + /// Gets the absolute and relative URIs associated to the pushed authorization endpoint. + /// + public List PushedAuthorizationEndpointUris { get; } = []; + /// /// Gets the absolute and relative URIs associated to the revocation endpoint. /// @@ -205,6 +210,13 @@ public sealed class OpenIddictServerOptions /// public TimeSpan? IdentityTokenLifetime { get; set; } = TimeSpan.FromMinutes(20); + /// + /// Gets or sets the period of time request tokens remain valid after being issued. The default value is 1 hour. + /// The client application is expected to start a whole new authentication flow after the request token has expired. + /// While not recommended, this property can be set to to issue request tokens that never expire. + /// + public TimeSpan? RequestTokenLifetime { get; set; } = TimeSpan.FromHours(1); + /// /// Gets or sets the period of time refresh tokens remain valid after being issued. The default value is 14 days. /// The client application is expected to start a whole new authentication flow after the refresh token has expired. @@ -338,6 +350,20 @@ public sealed class OpenIddictServerOptions /// public bool DisableScopeValidation { get; set; } + /// + /// Gets or sets a boolean indicating whether requests received by the authorization + /// endpoint should be stored in the token store, which allows flowing + /// large payloads across requests. Enabling this option can be useful + /// for clients that do not supported pushed authorization requests. + /// + public bool EnableAuthorizationRequestCaching { get; set; } + + /// + /// Gets or sets a boolean indicating whether requests received + /// by the end session endpoint should be stored in the token store. + /// + public bool EnableEndSessionRequestCaching { get; set; } + /// /// Gets the OAuth 2.0 client assertion types enabled for this application. /// @@ -396,6 +422,14 @@ public sealed class OpenIddictServerOptions /// public bool RequireProofKeyForCodeExchange { get; set; } + /// + /// Gets or sets a boolean indicating whether pushed authorization requests must be used + /// by client applications when using an interactive flow like the authorization code or + /// implicit flows. If this property is set to , authorization requests + /// that don't contain a request_uri parameter will be automatically rejected by OpenIddict. + /// + public bool RequirePushedAuthorizationRequests { get; set; } + /// /// Gets the OAuth 2.0/OpenID Connect response types enabled for this application. /// diff --git a/src/OpenIddict.Validation.Owin/OpenIddict.Validation.Owin.csproj b/src/OpenIddict.Validation.Owin/OpenIddict.Validation.Owin.csproj index df6bb372..0cd57a61 100644 --- a/src/OpenIddict.Validation.Owin/OpenIddict.Validation.Owin.csproj +++ b/src/OpenIddict.Validation.Owin/OpenIddict.Validation.Owin.csproj @@ -14,7 +14,6 @@ - diff --git a/test/OpenIddict.Abstractions.Tests/Primitives/OpenIddictRequestTests.cs b/test/OpenIddict.Abstractions.Tests/Primitives/OpenIddictRequestTests.cs index 50d40712..c6859ede 100644 --- a/test/OpenIddict.Abstractions.Tests/Primitives/OpenIddictRequestTests.cs +++ b/test/OpenIddict.Abstractions.Tests/Primitives/OpenIddictRequestTests.cs @@ -226,13 +226,6 @@ public class OpenIddictRequestTests /* value: */ new OpenIddictParameter("802A3E3E-DCCA-4EFC-89FA-7D82FE8C27E4") }; - yield return new object[] - { - /* property: */ nameof(OpenIddictRequest.RequestId), - /* name: */ Parameters.RequestId, - /* value: */ new OpenIddictParameter("802A3E3E-DCCA-4EFC-89FA-7D82FE8C27E4") - }; - yield return new object[] { /* property: */ nameof(OpenIddictRequest.RequestUri), diff --git a/test/OpenIddict.Abstractions.Tests/Primitives/OpenIddictResponseTests.cs b/test/OpenIddict.Abstractions.Tests/Primitives/OpenIddictResponseTests.cs index 50dcdcd6..d55e4893 100644 --- a/test/OpenIddict.Abstractions.Tests/Primitives/OpenIddictResponseTests.cs +++ b/test/OpenIddict.Abstractions.Tests/Primitives/OpenIddictResponseTests.cs @@ -85,6 +85,13 @@ public class OpenIddictResponseTests /* value: */ new OpenIddictParameter("802A3E3E-DCCA-4EFC-89FA-7D82FE8C27E4") }; + yield return new object[] + { + /* property: */ nameof(OpenIddictResponse.RequestUri), + /* name: */ Parameters.RequestUri, + /* value: */ new OpenIddictParameter("802A3E3E-DCCA-4EFC-89FA-7D82FE8C27E4") + }; + yield return new object[] { /* property: */ nameof(OpenIddictResponse.Scope), diff --git a/test/OpenIddict.Server.AspNetCore.IntegrationTests/OpenIddictServerAspNetCoreIntegrationTests.Authentication.cs b/test/OpenIddict.Server.AspNetCore.IntegrationTests/OpenIddictServerAspNetCoreIntegrationTests.Authentication.cs deleted file mode 100644 index de4307c9..00000000 --- a/test/OpenIddict.Server.AspNetCore.IntegrationTests/OpenIddictServerAspNetCoreIntegrationTests.Authentication.cs +++ /dev/null @@ -1,57 +0,0 @@ -/* - * 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 Microsoft.Extensions.DependencyInjection; -using OpenIddict.Server.IntegrationTests; -using Xunit; - -namespace OpenIddict.Server.AspNetCore.IntegrationTests; - -public partial class OpenIddictServerAspNetCoreIntegrationTests : OpenIddictServerIntegrationTests -{ - [Fact(Skip = "The handler responsible for rejecting such requests has not been ported yet.")] - public async Task ExtractAuthorizationRequest_RequestIdParameterIsRejectedWhenRequestCachingIsDisabled() - { - // Arrange - await using var server = await CreateServerAsync(options => options.EnableDegradedMode()); - await using var client = await server.CreateClientAsync(); - - // Act - var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest - { - RequestId = "EFAF3596-F868-497F-96BB-AA2AD1F8B7E7" - }); - - // Assert - Assert.Equal(Errors.InvalidRequest, response.Error); - Assert.Equal(SR.FormatID2028(Parameters.RequestId), response.ErrorDescription); - } - - [Fact] - public async Task ExtractAuthorizationRequest_InvalidRequestIdParameterIsRejected() - { - // Arrange - await using var server = await CreateServerAsync(options => - { - options.Services.AddDistributedMemoryCache(); - - options.UseAspNetCore() - .EnableAuthorizationRequestCaching(); - }); - - await using var client = await server.CreateClientAsync(); - - // Act - var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest - { - RequestId = "EFAF3596-F868-497F-96BB-AA2AD1F8B7E7" - }); - - // Assert - Assert.Equal(Errors.InvalidRequest, response.Error); - Assert.Equal(SR.FormatID2052(Parameters.RequestId), response.ErrorDescription); - } -} diff --git a/test/OpenIddict.Server.AspNetCore.IntegrationTests/OpenIddictServerAspNetCoreIntegrationTests.Session.cs b/test/OpenIddict.Server.AspNetCore.IntegrationTests/OpenIddictServerAspNetCoreIntegrationTests.Session.cs deleted file mode 100644 index e7ee4c5f..00000000 --- a/test/OpenIddict.Server.AspNetCore.IntegrationTests/OpenIddictServerAspNetCoreIntegrationTests.Session.cs +++ /dev/null @@ -1,57 +0,0 @@ -/* - * 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 Microsoft.Extensions.DependencyInjection; -using OpenIddict.Server.IntegrationTests; -using Xunit; - -namespace OpenIddict.Server.AspNetCore.IntegrationTests; - -public partial class OpenIddictServerAspNetCoreIntegrationTests : OpenIddictServerIntegrationTests -{ - [Fact(Skip = "The handler responsible for rejecting such requests has not been ported yet.")] - public async Task ExtractEndSessionRequest_RequestIdParameterIsRejectedWhenRequestCachingIsDisabled() - { - // Arrange - await using var server = await CreateServerAsync(options => options.EnableDegradedMode()); - await using var client = await server.CreateClientAsync(); - - // Act - var response = await client.PostAsync("/connect/endsession", new OpenIddictRequest - { - RequestId = "EFAF3596-F868-497F-96BB-AA2AD1F8B7E7" - }); - - // Assert - Assert.Equal(Errors.InvalidRequest, response.Error); - Assert.Equal(SR.FormatID2028(Parameters.RequestId), response.ErrorDescription); - } - - [Fact] - public async Task ExtractEndSessionRequest_InvalidRequestIdParameterIsRejected() - { - // Arrange - await using var server = await CreateServerAsync(options => - { - options.Services.AddDistributedMemoryCache(); - - options.UseAspNetCore() - .EnableEndSessionRequestCaching(); - }); - - await using var client = await server.CreateClientAsync(); - - // Act - var response = await client.PostAsync("/connect/endsession", new OpenIddictRequest - { - RequestId = "EFAF3596-F868-497F-96BB-AA2AD1F8B7E7" - }); - - // Assert - Assert.Equal(Errors.InvalidRequest, response.Error); - Assert.Equal(SR.FormatID2052(Parameters.RequestId), response.ErrorDescription); - } -} diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Authentication.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Authentication.cs index 14392037..e36f4f9d 100644 --- a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Authentication.cs +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Authentication.cs @@ -39,54 +39,6 @@ public abstract partial class OpenIddictServerIntegrationTests Assert.Equal(SR.FormatID8000(SR.ID2084), response.ErrorUri); } - [Fact] - public async Task ExtractAuthorizationRequest_UnsupportedRequestParameterIsRejected() - { - // Arrange - await using var server = await CreateServerAsync(options => options.EnableDegradedMode()); - await using var client = await server.CreateClientAsync(); - - // Act - var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest - { - ClientId = "Fabrikam", - RedirectUri = "http://www.fabrikam.com/path", - Request = "eyJhbGciOiJub25lIn0.eyJpc3MiOiJodHRwOi8vd3d3LmZhYnJpa2FtLmNvbSIsImF1ZCI6Imh0" + - "dHA6Ly93d3cuY29udG9zby5jb20iLCJyZXNwb25zZV90eXBlIjoiY29kZSIsImNsaWVudF9pZCI6" + - "IkZhYnJpa2FtIiwicmVkaXJlY3RfdXJpIjoiaHR0cDovL3d3dy5mYWJyaWthbS5jb20vcGF0aCJ9.", - ResponseType = ResponseTypes.Code, - Scope = Scopes.OpenId - }); - - // Assert - Assert.Equal(Errors.RequestNotSupported, response.Error); - Assert.Equal(SR.FormatID2028(Parameters.Request), response.ErrorDescription); - Assert.Equal(SR.FormatID8000(SR.ID2028), response.ErrorUri); - } - - [Fact] - public async Task ExtractAuthorizationRequest_UnsupportedRequestUriParameterIsRejected() - { - // Arrange - await using var server = await CreateServerAsync(options => options.EnableDegradedMode()); - await using var client = await server.CreateClientAsync(); - - // Act - var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest - { - ClientId = "Fabrikam", - RedirectUri = "http://www.fabrikam.com/path", - RequestUri = "http://www.fabrikam.com/request/GkurKxf5T0Y-mnPFCHqWOMiZi4VS138cQO_V7PZHAdM", - ResponseType = ResponseTypes.Code, - Scope = Scopes.OpenId - }); - - // Assert - Assert.Equal(Errors.RequestUriNotSupported, response.Error); - Assert.Equal(SR.FormatID2028(Parameters.RequestUri), response.ErrorDescription); - Assert.Equal(SR.FormatID8000(SR.ID2028), response.ErrorUri); - } - [Theory] [InlineData("custom_error", null, null)] [InlineData("custom_error", "custom_description", null)] @@ -179,6 +131,139 @@ public abstract partial class OpenIddictServerIntegrationTests Assert.Equal("Bob le Magnifique", (string?) response["name"]); } + [Fact] + public async Task ValidateAuthorizationRequest_UnsupportedRequestParameterIsRejected() + { + // Arrange + await using var server = await CreateServerAsync(options => options.EnableDegradedMode()); + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + Request = "eyJhbGciOiJub25lIn0.eyJpc3MiOiJodHRwOi8vd3d3LmZhYnJpa2FtLmNvbSIsImF1ZCI6Imh0" + + "dHA6Ly93d3cuY29udG9zby5jb20iLCJyZXNwb25zZV90eXBlIjoiY29kZSIsImNsaWVudF9pZCI6" + + "IkZhYnJpa2FtIiwicmVkaXJlY3RfdXJpIjoiaHR0cDovL3d3dy5mYWJyaWthbS5jb20vcGF0aCJ9.", + ResponseType = ResponseTypes.Code, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.RequestNotSupported, response.Error); + Assert.Equal(SR.FormatID2028(Parameters.Request), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2028), response.ErrorUri); + } + + [Fact] + public async Task ValidateAuthorizationRequest_UnsupportedRequestUriIsRejected() + { + // Arrange + await using var server = await CreateServerAsync(options => options.EnableDegradedMode()); + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + RequestUri = "http://www.fabrikam.com/request/GkurKxf5T0Y-mnPFCHqWOMiZi4VS138cQO_V7PZHAdM", + ResponseType = ResponseTypes.Code, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.RequestUriNotSupported, response.Error); + Assert.Equal(SR.FormatID2028(Parameters.RequestUri), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2028), response.ErrorUri); + } + + [Fact] + public async Task ValidateAuthorizationRequest_MissingRequestUriCausesAnErrorWhenPushedAuthorizationRequestsAreRequired() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + options.RequirePushedAuthorizationRequests(); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + RequestUri = null, + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.FormatID2029(Parameters.RequestUri), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2029), response.ErrorUri); + } + + [Fact] + public async Task ValidateAuthorizationRequest_ValidRequestUriDoesNotCauseAnError() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + options.RequirePushedAuthorizationRequests(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Principal = new ClaimsPrincipal(new ClaimsIdentity("Bearer")) + .SetClaim(Claims.Subject, "Bob le Magnifique"); + + return default; + })); + + options.AddEventHandler(builder => + { + builder.UseInlineHandler(context => + { + Assert.Equal("6esc_11ACC5bwc014ltc14eY22c", context.Token); + Assert.Equal([TokenTypeHints.Private.RequestToken], context.ValidTokenTypes); + + context.Principal = new ClaimsPrincipal(new ClaimsIdentity("Bearer")) + .SetTokenType(TokenTypeHints.Private.RequestToken) + .SetClaim(Claims.Private.RequestTokenType, RequestTokenTypes.Private.PushedAuthorizationRequest) + .SetClaim(Claims.Private.RequestParameters, $$""" + { + "client_id": "Fabrikam", + "redirect_uri": "http://www.fabrikam.com/path", + "response_type": "code", + "scope": "openid" + } + """); + + return default; + }); + + builder.SetOrder(ValidateIdentityModelToken.Descriptor.Order - 500); + }); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + RequestUri = RequestUris.Prefixes.Generic + "6esc_11ACC5bwc014ltc14eY22c" + }); + + // Assert + Assert.Null(response.Error); + Assert.Null(response.ErrorDescription); + Assert.Null(response.ErrorUri); + Assert.NotNull(response.Code); + } + [Fact] public async Task ValidateAuthorizationRequest_MissingClientIdCausesAnError() { @@ -636,6 +721,8 @@ public abstract partial class OpenIddictServerIntegrationTests // Arrange await using var server = await CreateServerAsync(options => { + options.EnableDegradedMode(); + options.Configure(options => options.GrantTypes.Remove(flow)); options.Configure(options => options.ResponseTypes.Clear()); }); @@ -672,6 +759,8 @@ public abstract partial class OpenIddictServerIntegrationTests // Arrange await using var server = await CreateServerAsync(options => { + options.EnableDegradedMode(); + options.Configure(options => options.ResponseTypes.Remove(type)); }); @@ -906,6 +995,8 @@ public abstract partial class OpenIddictServerIntegrationTests // Arrange await using var server = await CreateServerAsync(options => { + options.EnableDegradedMode(); + options.Configure(options => options.GrantTypes.Remove(GrantTypes.RefreshToken)); }); @@ -2822,4 +2913,2399 @@ public abstract partial class OpenIddictServerIntegrationTests // Assert Assert.Equal("http://www.contoso.com/", response.Iss); } + + [Theory] + [InlineData(nameof(HttpMethod.Delete))] + [InlineData(nameof(HttpMethod.Get))] + [InlineData(nameof(HttpMethod.Head))] + [InlineData(nameof(HttpMethod.Options))] + [InlineData(nameof(HttpMethod.Put))] + [InlineData(nameof(HttpMethod.Trace))] + public async Task ExtractPushedAuthorizationRequest_UnexpectedMethodReturnsAnError(string method) + { + // Arrange + await using var server = await CreateServerAsync(options => options.EnableDegradedMode()); + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.SendAsync(method, "/connect/par", new OpenIddictRequest()); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.GetResourceString(SR.ID2084), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2084), response.ErrorUri); + } + + [Theory] + [InlineData("custom_error", null, null)] + [InlineData("custom_error", "custom_description", null)] + [InlineData("custom_error", "custom_description", "custom_uri")] + [InlineData(null, "custom_description", null)] + [InlineData(null, "custom_description", "custom_uri")] + [InlineData(null, null, "custom_uri")] + [InlineData(null, null, null)] + public async Task ExtractPushedAuthorizationRequest_AllowsRejectingRequest(string error, string description, string uri) + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Reject(error, description, uri); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest()); + + // Assert + Assert.Equal(error ?? Errors.InvalidRequest, response.Error); + Assert.Equal(description, response.ErrorDescription); + Assert.Equal(uri, response.ErrorUri); + } + + [Fact] + public async Task ExtractPushedAuthorizationRequest_AllowsHandlingResponse() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Transaction.SetProperty("custom_response", new + { + name = "Bob le Bricoleur" + }); + + context.HandleRequest(); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest()); + + // Assert + Assert.Equal("Bob le Bricoleur", (string?) response["name"]); + } + + [Fact] + public async Task ExtractPushedAuthorizationRequest_AllowsSkippingHandler() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.SkipRequest(); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest()); + + // Assert + Assert.Equal("Bob le Magnifique", (string?) response["name"]); + } + + [Fact] + public async Task ExtractPushedAuthorizationRequest_UnsupportedRequestParameterIsRejected() + { + // Arrange + await using var server = await CreateServerAsync(options => options.EnableDegradedMode()); + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + Request = "eyJhbGciOiJub25lIn0.eyJpc3MiOiJodHRwOi8vd3d3LmZhYnJpa2FtLmNvbSIsImF1ZCI6Imh0" + + "dHA6Ly93d3cuY29udG9zby5jb20iLCJyZXNwb25zZV90eXBlIjoiY29kZSIsImNsaWVudF9pZCI6" + + "IkZhYnJpa2FtIiwicmVkaXJlY3RfdXJpIjoiaHR0cDovL3d3dy5mYWJyaWthbS5jb20vcGF0aCJ9.", + ResponseType = ResponseTypes.Code, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.RequestNotSupported, response.Error); + Assert.Equal(SR.FormatID2028(Parameters.Request), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2028), response.ErrorUri); + } + + [Fact] + public async Task ExtractPushedAuthorizationRequest_ForbiddenRequestUriParameterIsRejected() + { + // Arrange + await using var server = await CreateServerAsync(options => options.EnableDegradedMode()); + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + RequestUri = "http://www.fabrikam.com/request/GkurKxf5T0Y-mnPFCHqWOMiZi4VS138cQO_V7PZHAdM", + ResponseType = ResponseTypes.Code, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.FormatID2074(Parameters.RequestUri), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2074), response.ErrorUri); + } + + [Fact] + public async Task ValidatePushedAuthorizationRequest_MissingClientIdCausesAnError() + { + // Arrange + await using var server = await CreateServerAsync(); + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = null + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.FormatID2029(Parameters.ClientId), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2029), response.ErrorUri); + } + + [Fact] + public async Task ValidatePushedAuthorizationRequest_MissingRedirectUriCausesAnErrorForOpenIdRequests() + { + // Arrange + await using var server = await CreateServerAsync(options => options.EnableDegradedMode()); + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = null, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.FormatID2029(Parameters.RedirectUri), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2029), response.ErrorUri); + } + + [Theory] + [InlineData("/path", SR.ID2030)] + [InlineData("/tmp/file.xml", SR.ID2030)] + [InlineData("C:\\tmp\\file.xml", SR.ID2030)] + [InlineData("http://www.fabrikam.com/path#param=value", SR.ID2031)] + public async Task ValidatePushedAuthorizationRequest_InvalidRedirectUriCausesAnError(string uri, string message) + { + // Arrange + await using var server = await CreateServerAsync(options => options.EnableDegradedMode()); + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = uri, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(string.Format(SR.GetResourceString(message), Parameters.RedirectUri), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(message), response.ErrorUri); + } + + [Theory] + [InlineData("http://www.fabrikam.com/path?iss")] + [InlineData("http://www.fabrikam.com/path?iss=value")] + [InlineData("http://www.fabrikam.com/path?;iss")] + [InlineData("http://www.fabrikam.com/path?;iss=value")] + [InlineData("http://www.fabrikam.com/path?&iss")] + [InlineData("http://www.fabrikam.com/path?&iss=value")] + [InlineData("http://www.fabrikam.com/path?state;iss")] + [InlineData("http://www.fabrikam.com/path?state;iss=value")] + [InlineData("http://www.fabrikam.com/path?state&iss")] + [InlineData("http://www.fabrikam.com/path?state&iss=value")] + [InlineData("http://www.fabrikam.com/path?state=abc;iss")] + [InlineData("http://www.fabrikam.com/path?state=abc;iss=value")] + [InlineData("http://www.fabrikam.com/path?state=abc&iss")] + [InlineData("http://www.fabrikam.com/path?state=abc&iss=value")] + public async Task ValidatePushedAuthorizationRequest_RedirectUriWithIssuerParameterCausesAnError(string uri) + { + // Arrange + await using var server = await CreateServerAsync(options => options.EnableDegradedMode()); + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = uri, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.FormatID2135(Parameters.RedirectUri, Parameters.Iss), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2135), response.ErrorUri); + } + + [Fact] + public async Task ValidatePushedAuthorizationRequest_MissingResponseTypeCausesAnError() + { + // Arrange + await using var server = await CreateServerAsync(options => options.EnableDegradedMode()); + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = null, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.FormatID2029(Parameters.ResponseType), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2029), response.ErrorUri); + } + + [Theory] + [InlineData("code id_token", ResponseModes.Query)] + [InlineData("code id_token token", ResponseModes.Query)] + [InlineData("code token", ResponseModes.Query)] + [InlineData("id_token", ResponseModes.Query)] + [InlineData("id_token token", ResponseModes.Query)] + [InlineData("token", ResponseModes.Query)] + public async Task ValidatePushedAuthorizationRequest_UnsafeResponseModeCausesAnError(string type, string mode) + { + // Arrange + await using var server = await CreateServerAsync(options => options.EnableDegradedMode()); + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseMode = mode, + ResponseType = type, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.FormatID2033(Parameters.ResponseType, Parameters.ResponseMode), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2033), response.ErrorUri); + } + + [Theory] + [InlineData("code id_token")] + [InlineData("code id_token token")] + [InlineData("code token")] + [InlineData("id_token")] + [InlineData("id_token token")] + [InlineData("token")] + public async Task ValidatePushedAuthorizationRequest_MissingNonceCausesAnErrorForOpenIdRequests(string type) + { + // Arrange + await using var server = await CreateServerAsync(options => options.EnableDegradedMode()); + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = type, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.FormatID2029(Parameters.Nonce), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2029), response.ErrorUri); + } + + [Theory] + [InlineData("code id_token")] + [InlineData("code id_token token")] + [InlineData("id_token")] + [InlineData("id_token token")] + public async Task ValidatePushedAuthorizationRequest_MissingOpenIdScopeCausesAnErrorForOpenIdRequests(string type) + { + // Arrange + await using var server = await CreateServerAsync(options => options.EnableDegradedMode()); + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = type + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.FormatID2034(Scopes.OpenId), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2034), response.ErrorUri); + } + + [Fact] + public async Task ValidatePushedAuthorizationRequest_UnsupportedPromptCausesAnError() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.Configure(options => options.PromptValues.Remove(PromptValues.SelectAccount)); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + Nonce = "n-0S6_WzA2Mj", + Prompt = PromptValues.SelectAccount, + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = "code id_token token", + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.FormatID2032(Parameters.Prompt), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2032), response.ErrorUri); + } + + [Theory] + [InlineData("none")] + [InlineData("consent")] + [InlineData("login")] + [InlineData("select_account")] + [InlineData("consent login")] + [InlineData("consent select_account")] + [InlineData("login select_account")] + [InlineData("consent login select_account")] + public async Task ValidatePushedAuthorizationRequest_ValidPromptDoesNotCauseAnError(string prompt) + { + // Arrange + await using var server = await CreateServerAsync(options => options.EnableDegradedMode()); + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + Nonce = "n-0S6_WzA2Mj", + Prompt = prompt, + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = "code id_token token", + Scope = Scopes.OpenId + }); + + // Assert + Assert.Null(response.Error); + Assert.Null(response.ErrorDescription); + Assert.Null(response.ErrorUri); + Assert.NotNull(response.RequestUri); + } + + [Fact] + public async Task ValidatePushedAuthorizationRequest_RequestIsRejectedWhenPkceIsRequiredAndCodeChallengeIsMissing() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + options.RequireProofKeyForCodeExchange(); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + CodeChallenge = null, + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.FormatID2029(Parameters.CodeChallenge), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2029), response.ErrorUri); + } + + [Fact] + public async Task ValidatePushedAuthorizationRequest_RequestIsValidatedWhenPkceIsNotRequiredAndCodeChallengeIsMissing() + { + // Arrange + await using var server = await CreateServerAsync(options => options.EnableDegradedMode()); + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + CodeChallenge = null, + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code + }); + + // Assert + Assert.Null(response.Error); + Assert.Null(response.ErrorDescription); + Assert.Null(response.ErrorUri); + Assert.NotNull(response.RequestUri); + } + + [Theory] + [InlineData("id_token")] + [InlineData("id_token token")] + [InlineData("token")] + public async Task ValidatePushedAuthorizationRequest_MissingCodeResponseTypeCausesAnErrorWhenCodeChallengeIsUsed(string type) + { + // Arrange + await using var server = await CreateServerAsync(options => options.EnableDegradedMode()); + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + CodeChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + CodeChallengeMethod = CodeChallengeMethods.Sha256, + Nonce = "n-0S6_WzA2Mj", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = type, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.FormatID2040(Parameters.CodeChallenge, Parameters.CodeChallengeMethod, ResponseTypes.Code), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2040), response.ErrorUri); + } + + [Fact] + public async Task ValidatePushedAuthorizationRequest_MissingCodeChallengeCausesAnErrorWhenCodeChallengeMethodIsSpecified() + { + // Arrange + await using var server = await CreateServerAsync(options => options.EnableDegradedMode()); + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + CodeChallengeMethod = CodeChallengeMethods.Sha256, + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.FormatID2037(Parameters.CodeChallengeMethod, Parameters.CodeChallenge), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2037), response.ErrorUri); + } + + [Fact] + public async Task ValidatePushedAuthorizationRequest_InvalidCodeChallengeMethodCausesAnError() + { + // Arrange + await using var server = await CreateServerAsync(options => options.EnableDegradedMode()); + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + CodeChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + CodeChallengeMethod = "invalid_code_challenge_method", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.FormatID2032(Parameters.CodeChallengeMethod), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2032), response.ErrorUri); + } + + [Fact] + public async Task ValidatePushedAuthorizationRequest_UnknownResponseTypeParameterIsRejected() + { + // Arrange + await using var server = await CreateServerAsync(options => options.EnableDegradedMode()); + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = "unknown_response_type" + }); + + // Assert + Assert.Equal(Errors.UnsupportedResponseType, response.Error); + Assert.Equal(SR.FormatID2032(Parameters.ResponseType), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2032), response.ErrorUri); + } + + [Theory] + [InlineData(GrantTypes.AuthorizationCode, "code")] + [InlineData(GrantTypes.AuthorizationCode, "code id_token")] + [InlineData(GrantTypes.AuthorizationCode, "code id_token token")] + [InlineData(GrantTypes.AuthorizationCode, "code token")] + [InlineData(GrantTypes.Implicit, "code id_token")] + [InlineData(GrantTypes.Implicit, "code id_token token")] + [InlineData(GrantTypes.Implicit, "code token")] + [InlineData(GrantTypes.Implicit, "id_token")] + [InlineData(GrantTypes.Implicit, "id_token token")] + [InlineData(GrantTypes.Implicit, "token")] + public async Task ValidatePushedAuthorizationRequest_RequestIsRejectedWhenGrantTypeIsDisabled(string flow, string type) + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.Configure(options => options.GrantTypes.Remove(flow)); + options.Configure(options => options.ResponseTypes.Clear()); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + Nonce = "n-0S6_WzA2Mj", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = type, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.UnsupportedResponseType, response.Error); + Assert.Equal(SR.FormatID2032(Parameters.ResponseType), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2032), response.ErrorUri); + } + + [Theory] + [InlineData("code")] + [InlineData("code id_token")] + [InlineData("code id_token token")] + [InlineData("code token")] + [InlineData("id_token")] + [InlineData("id_token token")] + [InlineData("none")] + [InlineData("token")] + public async Task ValidatePushedAuthorizationRequest_RequestIsRejectedWhenResponseTypeIsDisabled(string type) + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.Configure(options => options.ResponseTypes.Remove(type)); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + Nonce = "n-0S6_WzA2Mj", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = type, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.UnsupportedResponseType, response.Error); + Assert.Equal(SR.FormatID2032(Parameters.ResponseType), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2032), response.ErrorUri); + } + + [Theory] + [InlineData("none code")] + [InlineData("none code id_token")] + [InlineData("none code id_token token")] + [InlineData("none code token")] + [InlineData("none id_token")] + [InlineData("none id_token token")] + [InlineData("none token")] + public async Task ValidatePushedAuthorizationRequest_InvalidResponseTypeParameterIsRejected(string type) + { + // Arrange + await using var server = await CreateServerAsync(options => options.EnableDegradedMode()); + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = type + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.FormatID2052(Parameters.ResponseType), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2052), response.ErrorUri); + } + + [Fact] + public async Task ValidatePushedAuthorizationRequest_UnsupportedResponseModeCausesAnError() + { + // Arrange + await using var server = await CreateServerAsync(options => options.EnableDegradedMode()); + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseMode = "unsupported_response_mode", + ResponseType = ResponseTypes.Code, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.FormatID2032(Parameters.ResponseMode), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2032), response.ErrorUri); + } + + [Fact] + public async Task ValidatePushedAuthorizationRequest_RequestIsRejectedWhenUnregisteredScopeIsSpecified() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.Services.AddSingleton(CreateApplicationManager(mock => + { + var application = new OpenIddictApplication(); + + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.ValidateRedirectUriAsync(application, "http://www.fabrikam.com/path", It.IsAny())) + .ReturnsAsync(true); + + mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) + .ReturnsAsync(true); + })); + + options.Services.AddSingleton(CreateScopeManager(mock => + { + mock.Setup(manager => manager.FindByNamesAsync( + It.Is>(scopes => scopes.Length == 1 && scopes[0] == "unregistered_scope"), + It.IsAny())) + .Returns(AsyncEnumerable.Empty()); + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code, + Scope = "unregistered_scope" + }); + + // Assert + Assert.Equal(Errors.InvalidScope, response.Error); + Assert.Equal(SR.FormatID2052(Parameters.Scope), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2052), response.ErrorUri); + } + + [Fact] + public async Task ValidatePushedAuthorizationRequest_RequestIsValidatedWhenScopeRegisteredInOptionsIsSpecified() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + options.RegisterScopes("registered_scope"); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + Nonce = "n-0S6_WzA2Mj", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Token, + Scope = "registered_scope" + }); + + // Assert + Assert.Null(response.Error); + Assert.Null(response.ErrorDescription); + Assert.Null(response.ErrorUri); + Assert.NotNull(response.RequestUri); + } + + [Fact] + public async Task ValidatePushedAuthorizationRequest_RequestIsValidatedWhenRegisteredScopeIsSpecified() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + var scope = new OpenIddictScope(); + + options.RegisterScopes("scope_registered_in_options"); + options.SetDeviceAuthorizationEndpointUris(Array.Empty()); + options.SetRevocationEndpointUris(Array.Empty()); + options.Configure(options => options.GrantTypes.Remove(GrantTypes.DeviceCode)); + options.DisableTokenStorage(); + options.DisableSlidingRefreshTokenExpiration(); + + options.Services.AddSingleton(CreateApplicationManager(mock => + { + var application = new OpenIddictApplication(); + + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.ValidateRedirectUriAsync(application, "http://www.fabrikam.com/path", It.IsAny())) + .ReturnsAsync(true); + + mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) + .ReturnsAsync(true); + + mock.Setup(manager => manager.GetSettingsAsync(application, It.IsAny())) + .ReturnsAsync(ImmutableDictionary.Create()); + })); + + options.Services.AddSingleton(CreateScopeManager(mock => + { + mock.Setup(manager => manager.FindByNamesAsync( + It.Is>(scopes => scopes.Length == 1 && scopes[0] == "scope_registered_in_database"), + It.IsAny())) + .Returns(new[] { scope }.ToAsyncEnumerable()); + + mock.Setup(manager => manager.GetNameAsync(scope, It.IsAny())) + .ReturnsAsync("scope_registered_in_database"); + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + Nonce = "n-0S6_WzA2Mj", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Token, + Scope = "scope_registered_in_database scope_registered_in_options" + }); + + // Assert + Assert.Null(response.Error); + Assert.Null(response.ErrorDescription); + Assert.Null(response.ErrorUri); + Assert.NotNull(response.RequestUri); + } + + [Fact] + public async Task ValidatePushedAuthorizationRequest_RequestWithOfflineAccessScopeIsRejectedWhenRefreshTokenFlowIsDisabled() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.Configure(options => options.GrantTypes.Remove(GrantTypes.RefreshToken)); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code, + Scope = Scopes.OfflineAccess + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.FormatID2035(Scopes.OfflineAccess), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2035), response.ErrorUri); + } + + [Fact] + public async Task ValidatePushedAuthorizationRequest_UnknownResponseModeParameterIsRejected() + { + // Arrange + await using var server = await CreateServerAsync(options => options.EnableDegradedMode()); + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseMode = "unknown_response_mode", + ResponseType = ResponseTypes.Code + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.FormatID2032(Parameters.ResponseMode), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2032), response.ErrorUri); + } + + [Fact] + public async Task ValidatePushedAuthorizationRequest_RequestIsRejectedWhenCodeChallengeMethodIsMissingAndPlainIsNotSupported() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + options.Services.PostConfigure(options => + options.CodeChallengeMethods.Remove(CodeChallengeMethods.Plain)); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + CodeChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + CodeChallengeMethod = null, + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.FormatID2029(Parameters.CodeChallengeMethod), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2029), response.ErrorUri); + } + + [Fact] + public async Task ValidatePushedAuthorizationRequest_RequestIsRejectedWhenCodeChallengeMethodIsNotEnabled() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + options.Services.PostConfigure(options => + options.CodeChallengeMethods.Clear()); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + CodeChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + CodeChallengeMethod = CodeChallengeMethods.Sha256, + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.FormatID2032(Parameters.CodeChallengeMethod), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2032), response.ErrorUri); + } + + [Theory] + [InlineData(CodeChallengeMethods.Plain)] + [InlineData(CodeChallengeMethods.Sha256)] + [InlineData("custom_code_challenge_method")] + public async Task ValidatePushedAuthorizationRequest_RequestIsValidatedWhenCodeChallengeMethodIsRegistered(string method) + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + options.Configure(options => options.CodeChallengeMethods.Clear()); + options.Configure(options => options.CodeChallengeMethods.Add(method)); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + CodeChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + CodeChallengeMethod = method, + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code + }); + + // Assert + Assert.Null(response.Error); + Assert.Null(response.ErrorDescription); + Assert.Null(response.ErrorUri); + Assert.NotNull(response.RequestUri); + } + + [Fact] + public async Task ValidatePushedAuthorizationRequest_RequestIsRejectedWhenRedirectUriIsMissing() + { + // Arrange + await using var server = await CreateServerAsync(options => options.EnableDegradedMode()); + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = null, + ResponseType = ResponseTypes.Code, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.FormatID2029(Parameters.RedirectUri), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2029), response.ErrorUri); + } + + [Fact] + public async Task ValidatePushedAuthorizationRequest_MissingRedirectUriCausesAnException() + { + // Arrange + await using var server = await CreateServerAsync(options => options.EnableDegradedMode()); + await using var client = await server.CreateClientAsync(); + + // Act and assert + var exception = await Assert.ThrowsAsync(delegate + { + return client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = null, + ResponseType = ResponseTypes.Code + }); + }); + + // Assert + Assert.Equal(SR.GetResourceString(SR.ID0028), exception.Message); + } + + [Fact] + public async Task ValidatePushedAuthorizationRequest_InvalidRedirectUriCausesAnException() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.SetRedirectUri("http://www.contoso.com/path"); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act and assert + var exception = await Assert.ThrowsAsync(delegate + { + return client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code + }); + }); + + // Assert + Assert.Equal(SR.GetResourceString(SR.ID0101), exception.Message); + } + + [Fact] + public async Task ValidatePushedAuthorizationRequest_RequestIsRejectedWhenClientCannotBeFound() + { + // Arrange + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(value: null); + }); + + await using var server = await CreateServerAsync(options => + { + options.Services.AddSingleton(manager); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.FormatID2052(Parameters.ClientId), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2052), response.ErrorUri); + + Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); + } + + [Theory] + [InlineData("code id_token token")] + [InlineData("code token")] + public async Task ValidatePushedAuthorizationRequest_PkceRequestWithSensitiveResponseTypeIsRejectedWhenDegradedModeIsEnabled(string type) + { + // Arrange + await using var server = await CreateServerAsync(options => options.EnableDegradedMode()); + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + CodeChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + CodeChallengeMethod = CodeChallengeMethods.Sha256, + Nonce = "n-0S6_WzA2Mj", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = type, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.UnauthorizedClient, response.Error); + Assert.Equal(SR.FormatID2041(Parameters.ResponseType), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2041), response.ErrorUri); + } + + [Theory] + [InlineData("code id_token token")] + [InlineData("code token")] + public async Task ValidatePushedAuthorizationRequest_PkceRequestWithSensitiveResponseTypeIsRejectedWhenPermissionsAreIgnored(string type) + { + // Arrange + var application = new OpenIddictApplication(); + + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.ValidateRedirectUriAsync(application, "http://www.fabrikam.com/path", It.IsAny())) + .ReturnsAsync(true); + + mock.Setup(manager => manager.GetSettingsAsync(application, It.IsAny())) + .ReturnsAsync(ImmutableDictionary.Create()); + }); + + await using var server = await CreateServerAsync(options => options.Services.AddSingleton(manager)); + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + CodeChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + CodeChallengeMethod = CodeChallengeMethods.Sha256, + Nonce = "n-0S6_WzA2Mj", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = type, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.UnauthorizedClient, response.Error); + Assert.Equal(SR.FormatID2041(Parameters.ResponseType), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2041), response.ErrorUri); + } + + [Theory] + [InlineData("code id_token token")] + [InlineData("code token")] + public async Task ValidatePushedAuthorizationRequest_PkceRequestWithSensitiveResponseTypeIsValidatedWhenPermissionsAreEnforced(string type) + { + // Arrange + var application = new OpenIddictApplication(); + + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.GetPermissionsAsync(application, It.IsAny())) + .ReturnsAsync(ImmutableArray.Create("rst:" + type)); + + mock.Setup(manager => manager.ValidateRedirectUriAsync(application, "http://www.fabrikam.com/path", It.IsAny())) + .ReturnsAsync(true); + + mock.Setup(manager => manager.GetSettingsAsync(application, It.IsAny())) + .ReturnsAsync(ImmutableDictionary.Create()); + }); + + await using var server = await CreateServerAsync(options => + { + options.SetDeviceAuthorizationEndpointUris(Array.Empty()); + options.SetRevocationEndpointUris(Array.Empty()); + options.Configure(options => options.GrantTypes.Remove(GrantTypes.DeviceCode)); + options.DisableAuthorizationStorage(); + options.DisableTokenStorage(); + options.DisableSlidingRefreshTokenExpiration(); + + options.Configure(options => options.IgnoreResponseTypePermissions = false); + + options.Services.AddSingleton(manager); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + CodeChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + CodeChallengeMethod = CodeChallengeMethods.Sha256, + Nonce = "n-0S6_WzA2Mj", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = type, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Null(response.Error); + Assert.Null(response.ErrorDescription); + Assert.Null(response.ErrorUri); + Assert.NotNull(response.RequestUri); + } + + [Theory] + [InlineData("code id_token token")] + [InlineData("code token")] + [InlineData("id_token token")] + [InlineData("token")] + public async Task ValidatePushedAuthorizationRequest_AnAccessTokenIsNotReturnedWhenClientIsConfidential(string type) + { + // Arrange + var application = new OpenIddictApplication(); + + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Confidential, It.IsAny())) + .ReturnsAsync(true); + + mock.Setup(manager => manager.GetPermissionsAsync(application, It.IsAny())) + .ReturnsAsync(ImmutableArray.Empty); + }); + + await using var server = await CreateServerAsync(options => + { + options.Services.AddSingleton(manager); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + Nonce = "n-0S6_WzA2Mj", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = type, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.UnauthorizedClient, response.Error); + Assert.Equal(SR.FormatID2043(Parameters.ResponseType), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2043), response.ErrorUri); + + Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); + Mock.Get(manager).Verify(manager => manager.HasClientTypeAsync(application, ClientTypes.Confidential, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task ValidatePushedAuthorizationRequest_RequestIsRejectedWhenEndpointPermissionIsNotGranted() + { + // Arrange + var application = new OpenIddictApplication(); + + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.ValidateRedirectUriAsync(application, "http://www.fabrikam.com/path", It.IsAny())) + .ReturnsAsync(true); + + mock.Setup(manager => manager.HasPermissionAsync(application, + Permissions.Endpoints.PushedAuthorization, It.IsAny())) + .ReturnsAsync(false); + }); + + await using var server = await CreateServerAsync(options => + { + options.Services.AddSingleton(manager); + + options.Configure(options => options.IgnoreEndpointPermissions = false); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code + }); + + // Assert + Assert.Equal(Errors.UnauthorizedClient, response.Error); + Assert.Equal(SR.GetResourceString(SR.ID2183), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2183), response.ErrorUri); + + Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); + Mock.Get(manager).Verify(manager => manager.HasPermissionAsync(application, + Permissions.Endpoints.PushedAuthorization, It.IsAny()), Times.Once()); + } + + [Theory] + [InlineData( + "code", + new[] { Permissions.GrantTypes.AuthorizationCode }, + SR.ID2047)] + [InlineData( + "code id_token", + new[] { Permissions.GrantTypes.AuthorizationCode, Permissions.GrantTypes.Implicit }, + SR.ID2049)] + [InlineData( + "code id_token token", + new[] { Permissions.GrantTypes.AuthorizationCode, Permissions.GrantTypes.Implicit }, + SR.ID2049)] + [InlineData( + "code token", + new[] { Permissions.GrantTypes.AuthorizationCode, Permissions.GrantTypes.Implicit }, + SR.ID2049)] + [InlineData( + "id_token", + new[] { Permissions.GrantTypes.Implicit }, + SR.ID2048)] + [InlineData( + "id_token token", + new[] { Permissions.GrantTypes.Implicit }, + SR.ID2048)] + [InlineData( + "token", + new[] { Permissions.GrantTypes.Implicit }, + SR.ID2048)] + public async Task ValidatePushedAuthorizationRequest_RequestIsRejectedWhenGrantTypePermissionIsNotGranted( + string type, string[] permissions, string description) + { + // Arrange + var application = new OpenIddictApplication(); + + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.ValidateRedirectUriAsync(application, "http://www.fabrikam.com/path", It.IsAny())) + .ReturnsAsync(true); + + foreach (var permission in permissions) + { + mock.Setup(manager => manager.HasPermissionAsync(application, permission, It.IsAny())) + .ReturnsAsync(false); + } + }); + + await using var server = await CreateServerAsync(options => + { + options.Services.AddSingleton(manager); + + options.Configure(options => options.IgnoreGrantTypePermissions = false); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + Nonce = "n-0S6_WzA2Mj", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = type, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.UnauthorizedClient, response.Error); + Assert.Equal(SR.GetResourceString(description), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(description), response.ErrorUri); + + Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); + Mock.Get(manager).Verify(manager => manager.HasPermissionAsync(application, permissions[0], It.IsAny()), Times.Once()); + } + + [Theory] + [InlineData("code")] + [InlineData("code id_token")] + [InlineData("code id_token token")] + [InlineData("code token")] + [InlineData("id_token")] + [InlineData("id_token token")] + [InlineData("none")] + [InlineData("token")] + public async Task ValidatePushedAuthorizationRequest_RequestIsRejectedWhenResponseTypePermissionIsNotGranted(string type) + { + // Arrange + var application = new OpenIddictApplication(); + + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.ValidateRedirectUriAsync(application, "http://www.fabrikam.com/path", It.IsAny())) + .ReturnsAsync(true); + + mock.Setup(manager => manager.GetPermissionsAsync(application, It.IsAny())) + .ReturnsAsync(ImmutableArray.Empty); + }); + + await using var server = await CreateServerAsync(options => + { + options.Services.AddSingleton(manager); + + options.Configure(options => options.IgnoreResponseTypePermissions = false); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + Nonce = "n-0S6_WzA2Mj", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = type, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.UnauthorizedClient, response.Error); + Assert.Equal(SR.FormatID2043(Parameters.ResponseType), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2043), response.ErrorUri); + + Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); + Mock.Get(manager).Verify(manager => manager.GetPermissionsAsync(application, It.IsAny()), Times.Once()); + } + + [Theory] + [InlineData("code id_token token")] + [InlineData("code token")] + [InlineData("id_token token")] + [InlineData("token")] + public async Task ValidatePushedAuthorizationRequest_RequestIsValidatedWhenExplicitPermissionIsGranted(string type) + { + // Arrange + var application = new OpenIddictApplication(); + + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.GetPermissionsAsync(application, It.IsAny())) + .ReturnsAsync(ImmutableArray.Create("rst:" + type)); + + mock.Setup(manager => manager.ValidateRedirectUriAsync(application, "http://www.fabrikam.com/path", It.IsAny())) + .ReturnsAsync(true); + + mock.Setup(manager => manager.GetSettingsAsync(application, It.IsAny())) + .ReturnsAsync(ImmutableDictionary.Create()); + }); + + await using var server = await CreateServerAsync(options => + { + options.Services.AddSingleton(manager); + + options.SetDeviceAuthorizationEndpointUris(Array.Empty()); + options.SetRevocationEndpointUris(Array.Empty()); + options.Configure(options => options.GrantTypes.Remove(GrantTypes.DeviceCode)); + options.DisableAuthorizationStorage(); + options.DisableTokenStorage(); + options.DisableSlidingRefreshTokenExpiration(); + + options.Configure(options => options.IgnoreResponseTypePermissions = false); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + Nonce = "n-0S6_WzA2Mj", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = type, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Null(response.Error); + Assert.Null(response.ErrorDescription); + Assert.Null(response.ErrorUri); + Assert.NotNull(response.RequestUri); + + Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); + Mock.Get(manager).Verify(manager => manager.GetPermissionsAsync(application, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task ValidatePushedAuthorizationRequest_RequestWithOfflineAccessScopeIsRejectedWhenRefreshTokenPermissionIsNotGranted() + { + // Arrange + var application = new OpenIddictApplication(); + + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.ValidateRedirectUriAsync(application, "http://www.fabrikam.com/path", It.IsAny())) + .ReturnsAsync(true); + + mock.Setup(manager => manager.HasPermissionAsync(application, + Permissions.GrantTypes.AuthorizationCode, It.IsAny())) + .ReturnsAsync(true); + + mock.Setup(manager => manager.HasPermissionAsync(application, + Permissions.GrantTypes.RefreshToken, It.IsAny())) + .ReturnsAsync(false); + }); + + await using var server = await CreateServerAsync(options => + { + options.Services.AddSingleton(manager); + + options.Configure(options => options.IgnoreGrantTypePermissions = false); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code, + Scope = Scopes.OfflineAccess + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.FormatID2065(Scopes.OfflineAccess), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2065), response.ErrorUri); + + Mock.Get(manager).Verify(manager => manager.HasPermissionAsync(application, + Permissions.GrantTypes.RefreshToken, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task ValidatePushedAuthorizationRequest_RequestIsRejectedWhenRedirectUriIsInvalid() + { + // Arrange + var application = new OpenIddictApplication(); + + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.ValidateRedirectUriAsync(application, "http://www.fabrikam.com/path", It.IsAny())) + .ReturnsAsync(false); + }); + + await using var server = await CreateServerAsync(options => + { + options.Services.AddSingleton(manager); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.FormatID2043(Parameters.RedirectUri), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2043), response.ErrorUri); + + Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); + Mock.Get(manager).Verify(manager => manager.ValidateRedirectUriAsync(application, "http://www.fabrikam.com/path", It.IsAny()), Times.Once()); + } + + [Fact] + public async Task ValidatePushedAuthorizationRequest_RequestIsRejectedWhenScopePermissionIsNotGranted() + { + // Arrange + var application = new OpenIddictApplication(); + + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.ValidateRedirectUriAsync(application, "http://www.fabrikam.com/path", It.IsAny())) + .ReturnsAsync(true); + + mock.Setup(manager => manager.HasPermissionAsync(application, + Permissions.Prefixes.Scope + Scopes.Profile, It.IsAny())) + .ReturnsAsync(true); + + mock.Setup(manager => manager.HasPermissionAsync(application, + Permissions.Prefixes.Scope + Scopes.Email, It.IsAny())) + .ReturnsAsync(false); + }); + + await using var server = await CreateServerAsync(options => + { + options.Services.AddSingleton(manager); + options.RegisterScopes(Scopes.Email, Scopes.Profile); + options.Configure(options => options.IgnoreScopePermissions = false); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code, + Scope = "openid offline_access profile email" + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.GetResourceString(SR.ID2051), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2051), response.ErrorUri); + + Mock.Get(manager).Verify(manager => manager.HasPermissionAsync(application, + Permissions.Prefixes.Scope + Scopes.OpenId, It.IsAny()), Times.Never()); + Mock.Get(manager).Verify(manager => manager.HasPermissionAsync(application, + Permissions.Prefixes.Scope + Scopes.OfflineAccess, It.IsAny()), Times.Never()); + Mock.Get(manager).Verify(manager => manager.HasPermissionAsync(application, + Permissions.Prefixes.Scope + Scopes.Profile, It.IsAny()), Times.Once()); + Mock.Get(manager).Verify(manager => manager.HasPermissionAsync(application, + Permissions.Prefixes.Scope + Scopes.Email, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task ValidatePushedAuthorizationRequest_RequestIsRejectedWhenCodeChallengeIsMissingWithPkceFeatureEnforced() + { + // Arrange + var application = new OpenIddictApplication(); + + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.ValidateRedirectUriAsync(application, "http://www.fabrikam.com/path", It.IsAny())) + .ReturnsAsync(true); + + mock.Setup(manager => manager.HasRequirementAsync(application, + Requirements.Features.ProofKeyForCodeExchange, It.IsAny())) + .ReturnsAsync(true); + }); + + await using var server = await CreateServerAsync(options => + { + options.Services.AddSingleton(manager); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + CodeChallenge = null, + CodeChallengeMethod = null, + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.FormatID2054(Parameters.CodeChallenge), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2054), response.ErrorUri); + + Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); + Mock.Get(manager).Verify(manager => manager.HasRequirementAsync(application, + Requirements.Features.ProofKeyForCodeExchange, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task ValidatePushedAuthorizationRequest_RequestIsValidatedWhenCodeChallengeIsMissingWithPkceFeatureNotEnforced() + { + // Arrange + var application = new OpenIddictApplication(); + + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.ValidateRedirectUriAsync(application, "http://www.fabrikam.com/path", It.IsAny())) + .ReturnsAsync(true); + + mock.Setup(manager => manager.HasRequirementAsync(application, + Requirements.Features.ProofKeyForCodeExchange, It.IsAny())) + .ReturnsAsync(false); + + mock.Setup(manager => manager.GetSettingsAsync(application, It.IsAny())) + .ReturnsAsync(ImmutableDictionary.Create()); + }); + + await using var server = await CreateServerAsync(options => + { + options.SetDeviceAuthorizationEndpointUris(Array.Empty()); + options.SetRevocationEndpointUris(Array.Empty()); + options.Configure(options => options.GrantTypes.Remove(GrantTypes.DeviceCode)); + options.DisableAuthorizationStorage(); + options.DisableTokenStorage(); + options.DisableSlidingRefreshTokenExpiration(); + + options.Services.AddSingleton(manager); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + CodeChallenge = null, + CodeChallengeMethod = null, + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code + }); + + // Assert + Assert.NotNull(response.RequestUri); + + Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); + Mock.Get(manager).Verify(manager => manager.HasRequirementAsync(application, + Requirements.Features.ProofKeyForCodeExchange, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task ValidatePushedAuthorizationRequest_RequestIsValidatedWhenCodeChallengeIsPresentWithPkceFeatureEnforced() + { + // Arrange + var application = new OpenIddictApplication(); + + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.ValidateRedirectUriAsync(application, "http://www.fabrikam.com/path", It.IsAny())) + .ReturnsAsync(true); + + mock.Setup(manager => manager.HasRequirementAsync(application, + Requirements.Features.ProofKeyForCodeExchange, It.IsAny())) + .ReturnsAsync(true); + + mock.Setup(manager => manager.GetSettingsAsync(application, It.IsAny())) + .ReturnsAsync(ImmutableDictionary.Create()); + }); + + await using var server = await CreateServerAsync(options => + { + options.SetDeviceAuthorizationEndpointUris(Array.Empty()); + options.SetRevocationEndpointUris(Array.Empty()); + options.Configure(options => options.GrantTypes.Remove(GrantTypes.DeviceCode)); + options.DisableAuthorizationStorage(); + options.DisableTokenStorage(); + options.DisableSlidingRefreshTokenExpiration(); + + options.Services.AddSingleton(manager); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + CodeChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + CodeChallengeMethod = CodeChallengeMethods.Sha256, + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code + }); + + // Assert + Assert.NotNull(response.RequestUri); + + Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); + Mock.Get(manager).Verify(manager => manager.HasRequirementAsync(application, + Requirements.Features.ProofKeyForCodeExchange, It.IsAny()), Times.Never()); + } + + [Fact] + public async Task ValidatePushedAuthorizationRequest_RequestIsValidatedWhenCodeIsNotRequestedWithPkceFeatureEnforced() + { + // Arrange + var application = new OpenIddictApplication(); + + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.ValidateRedirectUriAsync(application, "http://www.fabrikam.com/path", It.IsAny())) + .ReturnsAsync(true); + + mock.Setup(manager => manager.HasRequirementAsync(application, + Requirements.Features.ProofKeyForCodeExchange, It.IsAny())) + .ReturnsAsync(true); + + mock.Setup(manager => manager.GetSettingsAsync(application, It.IsAny())) + .ReturnsAsync(ImmutableDictionary.Create()); + }); + + await using var server = await CreateServerAsync(options => + { + options.SetDeviceAuthorizationEndpointUris(Array.Empty()); + options.SetRevocationEndpointUris(Array.Empty()); + options.Configure(options => options.GrantTypes.Remove(GrantTypes.DeviceCode)); + options.DisableAuthorizationStorage(); + options.DisableTokenStorage(); + options.DisableSlidingRefreshTokenExpiration(); + + options.Services.AddSingleton(manager); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Token + }); + + // Assert + Assert.Null(response.Code); + Assert.NotNull(response.RequestUri); + + Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); + Mock.Get(manager).Verify(manager => manager.HasRequirementAsync(application, + Requirements.Features.ProofKeyForCodeExchange, It.IsAny()), Times.Never()); + } + + [Fact] + public async Task ValidatePushedAuthorizationRequest_InvalidIdentityTokenHintDoesNotCauseAnError() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + Assert.Null(context.IdentityTokenHintPrincipal); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + IdTokenHint = "id_token", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Token + }); + + // Assert + Assert.Null(response.Code); + Assert.NotNull(response.RequestUri); + } + + [Fact] + public async Task ValidatePushedAuthorizationRequest_InvalidIdentityTokenHintCausesAnErrorWhenRejectionIsEnabled() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + { + builder.UseInlineHandler(context => + { + context.RejectIdentityToken = true; + + return default; + }); + + builder.SetOrder(EvaluateValidatedTokens.Descriptor.Order + 500); + }); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + IdTokenHint = "id_token", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Token + }); + + // Assert + Assert.Equal(Errors.InvalidToken, response.Error); + Assert.Equal(SR.GetResourceString(SR.ID2009), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2009), response.ErrorUri); + } + + [Fact] + public async Task ValidatePushedAuthorizationRequest_IdentityTokenHintCausesAnErrorWhenCallerIsNotAuthorized() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + options.Configure(options => options.IgnoreEndpointPermissions = false); + + options.AddEventHandler(builder => + { + builder.UseInlineHandler(context => + { + Assert.Equal("id_token", context.Token); + Assert.Equal([TokenTypeHints.IdToken], context.ValidTokenTypes); + + context.Principal = new ClaimsPrincipal(new ClaimsIdentity("Bearer")) + .SetTokenType(TokenTypeHints.IdToken) + .SetPresenters("Contoso") + .SetClaim(Claims.Subject, "Bob le Bricoleur"); + + return default; + }); + + builder.SetOrder(ValidateIdentityModelToken.Descriptor.Order - 500); + }); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + IdTokenHint = "id_token", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Token + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.GetResourceString(SR.ID2141), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2141), response.ErrorUri); + } + + [Fact] + public async Task ValidatePushedAuthorizationRequest_RequestIsValidatedWhenIdentityTokenHintIsExpired() + { + // Arrange + var application = new OpenIddictApplication(); + + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.ValidateRedirectUriAsync(application, "http://www.fabrikam.com/path", It.IsAny())) + .ReturnsAsync(true); + + mock.Setup(manager => manager.HasPermissionAsync(application, + Permissions.Endpoints.PushedAuthorization, It.IsAny())) + .ReturnsAsync(true); + + mock.Setup(manager => manager.GetSettingsAsync(application, It.IsAny())) + .ReturnsAsync(ImmutableDictionary.Create()); + }); + + await using var server = await CreateServerAsync(options => + { + options.SetDeviceAuthorizationEndpointUris(Array.Empty()); + options.SetRevocationEndpointUris(Array.Empty()); + options.Configure(options => options.GrantTypes.Remove(GrantTypes.DeviceCode)); + options.DisableAuthorizationStorage(); + options.DisableTokenStorage(); + options.DisableSlidingRefreshTokenExpiration(); + + options.Configure(options => options.IgnoreEndpointPermissions = false); + + options.Services.AddSingleton(manager); + + options.AddEventHandler(builder => + { + builder.UseInlineHandler(context => + { + Assert.Equal("id_token", context.Token); + Assert.Equal([TokenTypeHints.IdToken], context.ValidTokenTypes); + + context.Principal = new ClaimsPrincipal(new ClaimsIdentity("Bearer")) + .SetTokenType(TokenTypeHints.IdToken) + .SetPresenters("Fabrikam") + .SetExpirationDate(new DateTimeOffset(2017, 1, 1, 0, 0, 0, TimeSpan.Zero)) + .SetClaim(Claims.Subject, "Bob le Bricoleur"); + + return default; + }); + + builder.SetOrder(ValidateIdentityModelToken.Descriptor.Order - 500); + }); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + Assert.Equal("Bob le Bricoleur", context.IdentityTokenHintPrincipal + ?.FindFirst(Claims.Subject)?.Value); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + IdTokenHint = "id_token", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Token + }); + + // Assert + Assert.Null(response.Code); + Assert.NotNull(response.RequestUri); + + Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); + Mock.Get(manager).Verify(manager => manager.ValidateRedirectUriAsync(application, "http://www.fabrikam.com/path", It.IsAny()), Times.Once()); + Mock.Get(manager).Verify(manager => manager.HasPermissionAsync(application, Permissions.Endpoints.PushedAuthorization, It.IsAny()), Times.Once()); + } + + [Theory] + [InlineData("custom_error", null, null)] + [InlineData("custom_error", "custom_description", null)] + [InlineData("custom_error", "custom_description", "custom_uri")] + [InlineData(null, "custom_description", null)] + [InlineData(null, "custom_description", "custom_uri")] + [InlineData(null, null, "custom_uri")] + [InlineData(null, null, null)] + public async Task ValidatePushedAuthorizationRequest_AllowsRejectingRequest(string error, string description, string uri) + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Reject(error, description, uri); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(error ?? Errors.InvalidRequest, response.Error); + Assert.Equal(description, response.ErrorDescription); + Assert.Equal(uri, response.ErrorUri); + } + + [Fact] + public async Task ValidatePushedAuthorizationRequest_AllowsHandlingResponse() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Transaction.SetProperty("custom_response", new + { + name = "Bob le Bricoleur" + }); + + context.HandleRequest(); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal("Bob le Bricoleur", (string?) response["name"]); + } + + [Fact] + public async Task ValidatePushedAuthorizationRequest_AllowsSkippingHandler() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.SkipRequest(); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal("Bob le Magnifique", (string?) response["name"]); + } + + [Theory] + [InlineData("custom_error", null, null)] + [InlineData("custom_error", "custom_description", null)] + [InlineData("custom_error", "custom_description", "custom_uri")] + [InlineData(null, "custom_description", null)] + [InlineData(null, "custom_description", "custom_uri")] + [InlineData(null, null, "custom_uri")] + [InlineData(null, null, null)] + public async Task HandlePushedAuthorizationRequest_AllowsRejectingRequest(string error, string description, string uri) + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Reject(error, description, uri); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(error ?? Errors.InvalidRequest, response.Error); + Assert.Equal(description, response.ErrorDescription); + Assert.Equal(uri, response.ErrorUri); + } + + [Fact] + public async Task HandlePushedAuthorizationRequest_AllowsHandlingResponse() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Transaction.SetProperty("custom_response", new + { + name = "Bob le Bricoleur" + }); + + context.HandleRequest(); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal("Bob le Bricoleur", (string?) response["name"]); + } + + [Fact] + public async Task HandlePushedAuthorizationRequest_AllowsSkippingHandler() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.SkipRequest(); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal("Bob le Magnifique", (string?) response["name"]); + } + + [Fact] + public async Task HandlePushedAuthorizationRequest_ResponseContainsCustomParameters() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Parameters["custom_parameter"] = "custom_value"; + context.Parameters["parameter_with_multiple_values"] = new[] + { + "custom_value_1", + "custom_value_2" + }; + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + Nonce = "n-0S6_WzA2Mj", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Token + }); + + // Assert + Assert.Null(response.Error); + Assert.Null(response.ErrorDescription); + Assert.Null(response.ErrorUri); + Assert.NotNull(response.RequestUri); + Assert.Equal("custom_value", (string?) response["custom_parameter"]); + Assert.Equal(["custom_value_1", "custom_value_2"], (string[]?) response["parameter_with_multiple_values"]); + } + + [Fact] + public async Task ApplyPushedAuthorizationResponse_AllowsHandlingResponse() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Transaction.SetProperty("custom_response", new + { + name = "Bob le Bricoleur" + }); + + context.HandleRequest(); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal("Bob le Bricoleur", (string?) response["name"]); + } + + [Fact] + public async Task ApplyPushedAuthorizationResponse_ResponseContainsCustomParameters() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Response["custom_parameter"] = "custom_value"; + context.Response["parameter_with_multiple_values"] = new[] + { + "custom_value_1", + "custom_value_2" + }; + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/par", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal("custom_value", (string?) response["custom_parameter"]); + Assert.Equal(["custom_value_1", "custom_value_2"], (string[]?) response["parameter_with_multiple_values"]); + } } diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Discovery.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Discovery.cs index 85ba749c..38714543 100644 --- a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Discovery.cs +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Discovery.cs @@ -317,6 +317,7 @@ public abstract partial class OpenIddictServerIntegrationTests .SetDeviceAuthorizationEndpointUris("path/device_endpoint") .SetIntrospectionEndpointUris("path/introspection_endpoint") .SetEndSessionEndpointUris("path/logout_endpoint") + .SetPushedAuthorizationEndpointUris("path/pushed_authorization_endpoint") .SetRevocationEndpointUris("path/revocation_endpoint") .SetTokenEndpointUris("path/token_endpoint") .SetUserInfoEndpointUris("path/userinfo_endpoint"); @@ -333,6 +334,7 @@ public abstract partial class OpenIddictServerIntegrationTests Assert.Equal("http://localhost/path/device_endpoint", (string?) response[Metadata.DeviceAuthorizationEndpoint]); Assert.Equal("http://localhost/path/introspection_endpoint", (string?) response[Metadata.IntrospectionEndpoint]); Assert.Equal("http://localhost/path/logout_endpoint", (string?) response[Metadata.EndSessionEndpoint]); + Assert.Equal("http://localhost/path/pushed_authorization_endpoint", (string?) response[Metadata.PushedAuthorizationRequestEndpoint]); Assert.Equal("http://localhost/path/revocation_endpoint", (string?) response[Metadata.RevocationEndpoint]); Assert.Equal("http://localhost/path/token_endpoint", (string?) response[Metadata.TokenEndpoint]); Assert.Equal("http://localhost/path/userinfo_endpoint", (string?) response[Metadata.UserInfoEndpoint]); @@ -349,6 +351,7 @@ public abstract partial class OpenIddictServerIntegrationTests .SetDeviceAuthorizationEndpointUris("path/device_endpoint") .SetIntrospectionEndpointUris("path/introspection_endpoint") .SetEndSessionEndpointUris("path/logout_endpoint") + .SetPushedAuthorizationEndpointUris("path/pushed_authorization_endpoint") .SetRevocationEndpointUris("path/revocation_endpoint") .SetTokenEndpointUris("path/token_endpoint") .SetUserInfoEndpointUris("path/userinfo_endpoint"); @@ -377,6 +380,7 @@ public abstract partial class OpenIddictServerIntegrationTests Assert.Equal("https://contoso.com/issuer/path/device_endpoint", (string?) response[Metadata.DeviceAuthorizationEndpoint]); Assert.Equal("https://contoso.com/issuer/path/introspection_endpoint", (string?) response[Metadata.IntrospectionEndpoint]); Assert.Equal("https://contoso.com/issuer/path/logout_endpoint", (string?) response[Metadata.EndSessionEndpoint]); + Assert.Equal("https://contoso.com/issuer/path/pushed_authorization_endpoint", (string?) response[Metadata.PushedAuthorizationRequestEndpoint]); Assert.Equal("https://contoso.com/issuer/path/revocation_endpoint", (string?) response[Metadata.RevocationEndpoint]); Assert.Equal("https://contoso.com/issuer/path/token_endpoint", (string?) response[Metadata.TokenEndpoint]); Assert.Equal("https://contoso.com/issuer/path/userinfo_endpoint", (string?) response[Metadata.UserInfoEndpoint]); @@ -556,6 +560,48 @@ public abstract partial class OpenIddictServerIntegrationTests Assert.Contains("custom", methods); } + [Fact] + public async Task HandleConfigurationRequest_NoClientAuthenticationMethodIsIncludedWhenPushedAuthorizationEndpointIsDisabled() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.SetPushedAuthorizationEndpointUris(Array.Empty()); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.GetAsync("/.well-known/openid-configuration"); + + // Assert + Assert.False(response.HasParameter(Metadata.PushedAuthorizationRequestEndpointAuthMethodsSupported)); + } + + [Fact] + public async Task HandleConfigurationRequest_SupportedClientAuthenticationMethodsAreIncludedWhenPushedAuthorizationEndpointIsEnabled() + { + // Arrange + await using var server = await CreateServerAsync(options => options.Configure(options => + { + options.ClientAuthenticationMethods.Remove(ClientAuthenticationMethods.ClientSecretBasic); + options.ClientAuthenticationMethods.Add("custom"); + })); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.GetAsync("/.well-known/openid-configuration"); + var methods = (string[]?) response[Metadata.PushedAuthorizationRequestEndpointAuthMethodsSupported]; + + // Assert + Assert.NotNull(methods); + Assert.Equal(3, methods.Length); + Assert.Contains(ClientAuthenticationMethods.ClientSecretPost, methods); + Assert.Contains(ClientAuthenticationMethods.PrivateKeyJwt, methods); + Assert.Contains("custom", methods); + } + [Fact] public async Task HandleConfigurationRequest_ConfiguredGrantTypesAreReturned() { @@ -930,6 +976,26 @@ public abstract partial class OpenIddictServerIntegrationTests Assert.False((bool?) response[Metadata.RequestUriParameterSupported]); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task HandleConfigurationRequest_PushedAuthorizationRequestsRequirementIsReflected(bool value) + { + // Arrange + await using var server = await CreateServerAsync(options => options.Configure(options => + { + options.RequirePushedAuthorizationRequests = value; + })); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.GetAsync("/.well-known/openid-configuration"); + + // Assert + Assert.Equal(value, (bool?) response[Metadata.RequirePushedAuthorizationRequests]); + } + [Theory] [InlineData("custom_error", null, null)] [InlineData("custom_error", "custom_description", null)] diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Session.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Session.cs index ae46b362..e5f02925 100644 --- a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Session.cs +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Session.cs @@ -131,12 +131,87 @@ public abstract partial class OpenIddictServerIntegrationTests Assert.Equal("Bob le Magnifique", (string?) response["name"]); } + [Fact] + public async Task ValidateEndSessionRequest_UnsupportedRequestUriIsRejected() + { + // Arrange + await using var server = await CreateServerAsync(options => options.EnableDegradedMode()); + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.GetAsync("/connect/endsession", new OpenIddictRequest + { + RequestUri = "http://www.fabrikam.com/request/GkurKxf5T0Y-mnPFCHqWOMiZi4VS138cQO_V7PZHAdM" + }); + + // Assert + Assert.Equal(Errors.RequestUriNotSupported, response.Error); + Assert.Equal(SR.FormatID2028(Parameters.RequestUri), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2028), response.ErrorUri); + } + + [Fact] + public async Task ValidateEndSession_ValidRequestUriDoesNotCauseAnError() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.SignOut(); + + return default; + })); + + options.AddEventHandler(builder => + { + builder.UseInlineHandler(context => + { + Assert.Equal("6esc_11ACC5bwc014ltc14eY22c", context.Token); + Assert.Equal([TokenTypeHints.Private.RequestToken], context.ValidTokenTypes); + + context.Principal = new ClaimsPrincipal(new ClaimsIdentity("Bearer")) + .SetTokenType(TokenTypeHints.Private.RequestToken) + .SetClaim(Claims.Private.RequestTokenType, RequestTokenTypes.Private.CachedEndSessionRequest) + .SetClaim(Claims.Private.RequestParameters, $$""" + { + "post_logout_redirect_uri": "http://www.fabrikam.com/path", + "state": "af0ifjsldkj" + } + """); + + return default; + }); + + builder.SetOrder(ValidateIdentityModelToken.Descriptor.Order - 500); + }); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.GetAsync("/connect/endsession", new OpenIddictRequest + { + ClientId = "Fabrikam", + RequestUri = RequestUris.Prefixes.Generic + "6esc_11ACC5bwc014ltc14eY22c" + }); + + // Assert + Assert.Null(response.Error); + Assert.Null(response.ErrorDescription); + Assert.Null(response.ErrorUri); + Assert.Equal("af0ifjsldkj", response.State); + } + [Theory] [InlineData("/path", SR.ID2030)] [InlineData("/tmp/file.xml", SR.ID2030)] [InlineData("C:\\tmp\\file.xml", SR.ID2030)] [InlineData("http://www.fabrikam.com/path#param=value", SR.ID2031)] - public async Task ValidateEndSessionRequest_InvalidRedirectUriCausesAnError(string uri, string message) + public async Task ValidateEndSessionRequest_InvalidPostLogoutRedirectUriCausesAnError(string uri, string message) { // Arrange await using var server = await CreateServerAsync(); diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs index 63468eb0..4bd5086d 100644 --- a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs @@ -98,6 +98,14 @@ public abstract partial class OpenIddictServerIntegrationTests [InlineData("/.WELL-KNOWN/JWKS/SUBPATH", OpenIddictServerEndpointType.Unknown)] [InlineData("/.well-known/jwks/subpath/", OpenIddictServerEndpointType.Unknown)] [InlineData("/.WELL-KNOWN/JWKS/SUBPATH/", OpenIddictServerEndpointType.Unknown)] + [InlineData("/connect/par", OpenIddictServerEndpointType.PushedAuthorization)] + [InlineData("/CONNECT/PAR", OpenIddictServerEndpointType.PushedAuthorization)] + [InlineData("/connect/par/", OpenIddictServerEndpointType.PushedAuthorization)] + [InlineData("/CONNECT/PAR/", OpenIddictServerEndpointType.PushedAuthorization)] + [InlineData("/connect/par/subpath", OpenIddictServerEndpointType.Unknown)] + [InlineData("/CONNECT/PAR/SUBPATH", OpenIddictServerEndpointType.Unknown)] + [InlineData("/connect/par/subpath/", OpenIddictServerEndpointType.Unknown)] + [InlineData("/CONNECT/PAR/SUBPATH/", OpenIddictServerEndpointType.Unknown)] [InlineData("/connect/revoke", OpenIddictServerEndpointType.Revocation)] [InlineData("/CONNECT/REVOKE", OpenIddictServerEndpointType.Revocation)] [InlineData("/connect/revoke/", OpenIddictServerEndpointType.Revocation)] @@ -284,6 +292,22 @@ public abstract partial class OpenIddictServerIntegrationTests [InlineData("HTTPS://LOCALHOST:8888/.WELL-KNOWN/JWKS", OpenIddictServerEndpointType.Unknown)] [InlineData("https://localhost:8888/.well-known/jwks/", OpenIddictServerEndpointType.Unknown)] [InlineData("HTTPS://LOCALHOST:8888/.WELL-KNOWN/JWKS/", OpenIddictServerEndpointType.Unknown)] + [InlineData("https://localhost/connect/par", OpenIddictServerEndpointType.PushedAuthorization)] + [InlineData("HTTPS://LOCALHOST/CONNECT/PAR", OpenIddictServerEndpointType.PushedAuthorization)] + [InlineData("https://localhost/connect/par/", OpenIddictServerEndpointType.PushedAuthorization)] + [InlineData("HTTPS://LOCALHOST/CONNECT/PAR/", OpenIddictServerEndpointType.PushedAuthorization)] + [InlineData("https://localhost:443/connect/par", OpenIddictServerEndpointType.PushedAuthorization)] + [InlineData("HTTPS://LOCALHOST:443/CONNECT/PAR", OpenIddictServerEndpointType.PushedAuthorization)] + [InlineData("https://localhost:443/connect/par/", OpenIddictServerEndpointType.PushedAuthorization)] + [InlineData("HTTPS://LOCALHOST:443/CONNECT/PAR/", OpenIddictServerEndpointType.PushedAuthorization)] + [InlineData("https://fabrikam.com/connect/par", OpenIddictServerEndpointType.Unknown)] + [InlineData("HTTPS://FABRIKAM.COM/CONNECT/PAR", OpenIddictServerEndpointType.Unknown)] + [InlineData("https://fabrikam.com/connect/par/", OpenIddictServerEndpointType.Unknown)] + [InlineData("HTTPS://FABRIKAM.COM/CONNECT/PAR/", OpenIddictServerEndpointType.Unknown)] + [InlineData("https://localhost:8888/connect/par", OpenIddictServerEndpointType.Unknown)] + [InlineData("HTTPS://LOCALHOST:8888/CONNECT/PAR", OpenIddictServerEndpointType.Unknown)] + [InlineData("https://localhost:8888/connect/par/", OpenIddictServerEndpointType.Unknown)] + [InlineData("HTTPS://LOCALHOST:8888/CONNECT/PAR/", OpenIddictServerEndpointType.Unknown)] [InlineData("https://localhost/connect/revoke", OpenIddictServerEndpointType.Revocation)] [InlineData("HTTPS://LOCALHOST/CONNECT/REVOKE", OpenIddictServerEndpointType.Revocation)] [InlineData("https://localhost/connect/revoke/", OpenIddictServerEndpointType.Revocation)] @@ -345,6 +369,7 @@ public abstract partial class OpenIddictServerIntegrationTests .SetDeviceAuthorizationEndpointUris("https://localhost/connect/device") .SetIntrospectionEndpointUris("https://localhost/connect/introspect") .SetEndSessionEndpointUris("https://localhost/connect/endsession") + .SetPushedAuthorizationEndpointUris("https://localhost/connect/par") .SetRevocationEndpointUris("https://localhost/connect/revoke") .SetTokenEndpointUris("https://localhost/connect/token") .SetUserInfoEndpointUris("https://localhost/connect/userinfo") @@ -390,6 +415,7 @@ public abstract partial class OpenIddictServerIntegrationTests [InlineData("/custom/connect/custom", OpenIddictServerEndpointType.Unknown)] [InlineData("/custom/connect/introspect", OpenIddictServerEndpointType.Introspection)] [InlineData("/custom/connect/endsession", OpenIddictServerEndpointType.EndSession)] + [InlineData("/custom/connect/par", OpenIddictServerEndpointType.PushedAuthorization)] [InlineData("/custom/connect/revoke", OpenIddictServerEndpointType.Revocation)] [InlineData("/custom/connect/token", OpenIddictServerEndpointType.Token)] [InlineData("/custom/connect/userinfo", OpenIddictServerEndpointType.UserInfo)] @@ -492,9 +518,9 @@ public abstract partial class OpenIddictServerIntegrationTests await using var server = await CreateServerAsync(options => { options.EnableDegradedMode(); - options.SetEndSessionEndpointUris("/authenticate"); + options.SetUserInfoEndpointUris("/authenticate"); - options.AddEventHandler(builder => + options.AddEventHandler(builder => builder.UseInlineHandler(context => { context.SkipRequest(); @@ -522,9 +548,9 @@ public abstract partial class OpenIddictServerIntegrationTests await using var server = await CreateServerAsync(options => { options.EnableDegradedMode(); - options.SetEndSessionEndpointUris("/authenticate"); + options.SetUserInfoEndpointUris("/authenticate"); - options.AddEventHandler(builder => + options.AddEventHandler(builder => builder.UseInlineHandler(context => { context.SkipRequest(); @@ -813,6 +839,210 @@ public abstract partial class OpenIddictServerIntegrationTests Assert.Equal("Bob le Magnifique", (string?) response[Claims.Subject]); } + [Fact] + public async Task ProcessAuthentication_RequestTokenPrincipalIsNotPopulatedWhenRequestTokenIsMissing() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + options.SetAuthorizationEndpointUris("/authenticate"); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.SkipRequest(); + + return default; + })); + + options.AddEventHandler(builder => + { + builder.UseInlineHandler(context => + { + // Assert + Assert.Null(context.RequestTokenPrincipal); + + return default; + }); + + builder.SetOrder(int.MaxValue); + }); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + await client.PostAsync("/authenticate", new OpenIddictRequest + { + ClientId = "Fabrikam", + Nonce = "n-0S6_WzA2Mj", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code, + Scope = Scopes.OpenId + }); + } + + [Fact] + public async Task ProcessAuthentication_RequestTokenPrincipalIsNotPopulatedWhenRequestTokenIsInvalid() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + options.SetAuthorizationEndpointUris("/authenticate"); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.SkipRequest(); + + return default; + })); + + options.AddEventHandler(builder => + { + builder.UseInlineHandler(context => + { + // Assert + Assert.Null(context.RequestTokenPrincipal); + + return default; + }); + + builder.SetOrder(int.MaxValue); + }); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + await client.PostAsync("/authenticate", new OpenIddictRequest + { + ClientId = "Fabrikam", + RequestUri = RequestUris.Prefixes.Generic + "request_token" + }); + } + + [Fact] + public async Task ProcessAuthentication_RequestTokenPrincipalIsPopulatedWhenRequestTokenTypeIsInvalid() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + options.SetAuthorizationEndpointUris("/authenticate"); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.SkipRequest(); + + return default; + })); + + options.AddEventHandler(builder => + { + builder.UseInlineHandler(context => + { + Assert.Equal("request_token", context.Token); + Assert.Equal([TokenTypeHints.Private.RequestToken], context.ValidTokenTypes); + + context.Principal = new ClaimsPrincipal(new ClaimsIdentity("Bearer")) + .SetTokenType(TokenTypeHints.Private.RequestToken) + .SetClaim(Claims.Private.RequestTokenType, RequestTokenTypes.Private.CachedEndSessionRequest); + + return default; + }); + + builder.SetOrder(ValidateIdentityModelToken.Descriptor.Order - 500); + }); + + options.AddEventHandler(builder => + { + builder.UseInlineHandler(context => + { + // Assert + Assert.NotNull(context.RequestTokenPrincipal); + Assert.NotNull(context.RequestTokenPrincipal.GetClaim(Claims.Private.RequestParameters)); + + return default; + }); + + builder.SetOrder(int.MaxValue); + }); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + await client.PostAsync("/authenticate", new OpenIddictRequest + { + ClientId = "Fabrikam", + RequestUri = RequestUris.Prefixes.Generic + "request_token" + }); + } + + [Fact] + public async Task ProcessAuthentication_RequestTokenPrincipalIsPopulatedWhenRequestTokenIsValid() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + options.SetAuthorizationEndpointUris("/authenticate"); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.SkipRequest(); + + return default; + })); + + options.AddEventHandler(builder => + { + builder.UseInlineHandler(context => + { + Assert.Equal("request_token", context.Token); + Assert.Equal([TokenTypeHints.Private.RequestToken], context.ValidTokenTypes); + + context.Principal = new ClaimsPrincipal(new ClaimsIdentity("Bearer")) + .SetTokenType(TokenTypeHints.Private.RequestToken) + .SetClaim(Claims.Private.RequestTokenType, RequestTokenTypes.Private.PushedAuthorizationRequest) + .SetClaim(Claims.Private.RequestParameters, "{}"); + + return default; + }); + + builder.SetOrder(ValidateIdentityModelToken.Descriptor.Order - 500); + }); + + options.AddEventHandler(builder => + { + builder.UseInlineHandler(context => + { + // Assert + Assert.NotNull(context.RequestTokenPrincipal); + Assert.NotNull(context.RequestTokenPrincipal.GetClaim(Claims.Private.RequestParameters)); + + return default; + }); + + builder.SetOrder(ValidateRequestTokenType.Descriptor.Order + 1); + }); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + await client.PostAsync("/authenticate", new OpenIddictRequest + { + ClientId = "Fabrikam", + RequestUri = RequestUris.Prefixes.Generic + "request_token" + }); + } + [Fact] public async Task ProcessAuthentication_MissingRefreshTokenReturnsNull() { @@ -3885,6 +4115,7 @@ public abstract partial class OpenIddictServerIntegrationTests .SetDeviceAuthorizationEndpointUris("/connect/device") .SetIntrospectionEndpointUris("/connect/introspect") .SetEndSessionEndpointUris("/connect/endsession") + .SetPushedAuthorizationEndpointUris("/connect/par") .SetRevocationEndpointUris("/connect/revoke") .SetTokenEndpointUris("/connect/token") .SetUserInfoEndpointUris("/connect/userinfo") @@ -3921,12 +4152,18 @@ public abstract partial class OpenIddictServerIntegrationTests options.AddEventHandler(builder => builder.UseInlineHandler(context => default)); + options.AddEventHandler(builder => + builder.UseInlineHandler(context => default)); + options.AddEventHandler(builder => builder.UseInlineHandler(context => default)); options.AddEventHandler(builder => builder.UseInlineHandler(context => default)); + options.AddEventHandler(builder => + builder.UseInlineHandler(context => default)); + options.AddEventHandler(builder => builder.UseInlineHandler(context => default)); diff --git a/test/OpenIddict.Server.Owin.IntegrationTests/OpenIddictServerOwinIntegrationTests.Authentication.cs b/test/OpenIddict.Server.Owin.IntegrationTests/OpenIddictServerOwinIntegrationTests.Authentication.cs deleted file mode 100644 index 756df1b2..00000000 --- a/test/OpenIddict.Server.Owin.IntegrationTests/OpenIddictServerOwinIntegrationTests.Authentication.cs +++ /dev/null @@ -1,57 +0,0 @@ -/* - * 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 Microsoft.Extensions.DependencyInjection; -using OpenIddict.Server.IntegrationTests; -using Xunit; - -namespace OpenIddict.Server.Owin.IntegrationTests; - -public partial class OpenIddictServerOwinIntegrationTests : OpenIddictServerIntegrationTests -{ - [Fact(Skip = "The handler responsible for rejecting such requests has not been ported yet.")] - public async Task ExtractAuthorizationRequest_RequestIdParameterIsRejectedWhenRequestCachingIsDisabled() - { - // Arrange - await using var server = await CreateServerAsync(options => options.EnableDegradedMode()); - await using var client = await server.CreateClientAsync(); - - // Act - var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest - { - RequestId = "EFAF3596-F868-497F-96BB-AA2AD1F8B7E7" - }); - - // Assert - Assert.Equal(Errors.InvalidRequest, response.Error); - Assert.Equal(SR.FormatID2028(Parameters.RequestId), response.ErrorDescription); - } - - [Fact] - public async Task ExtractAuthorizationRequest_InvalidRequestIdParameterIsRejected() - { - // Arrange - await using var server = await CreateServerAsync(options => - { - options.Services.AddDistributedMemoryCache(); - - options.UseOwin() - .EnableAuthorizationRequestCaching(); - }); - - await using var client = await server.CreateClientAsync(); - - // Act - var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest - { - RequestId = "EFAF3596-F868-497F-96BB-AA2AD1F8B7E7" - }); - - // Assert - Assert.Equal(Errors.InvalidRequest, response.Error); - Assert.Equal(SR.FormatID2052(Parameters.RequestId), response.ErrorDescription); - } -} diff --git a/test/OpenIddict.Server.Owin.IntegrationTests/OpenIddictServerOwinIntegrationTests.Session.cs b/test/OpenIddict.Server.Owin.IntegrationTests/OpenIddictServerOwinIntegrationTests.Session.cs deleted file mode 100644 index cb74988d..00000000 --- a/test/OpenIddict.Server.Owin.IntegrationTests/OpenIddictServerOwinIntegrationTests.Session.cs +++ /dev/null @@ -1,57 +0,0 @@ -/* - * 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 Microsoft.Extensions.DependencyInjection; -using OpenIddict.Server.IntegrationTests; -using Xunit; - -namespace OpenIddict.Server.Owin.IntegrationTests; - -public partial class OpenIddictServerOwinIntegrationTests : OpenIddictServerIntegrationTests -{ - [Fact(Skip = "The handler responsible for rejecting such requests has not been ported yet.")] - public async Task ExtractEndSessionRequest_RequestIdParameterIsRejectedWhenRequestCachingIsDisabled() - { - // Arrange - await using var server = await CreateServerAsync(options => options.EnableDegradedMode()); - await using var client = await server.CreateClientAsync(); - - // Act - var response = await client.PostAsync("/connect/endsession", new OpenIddictRequest - { - RequestId = "EFAF3596-F868-497F-96BB-AA2AD1F8B7E7" - }); - - // Assert - Assert.Equal(Errors.InvalidRequest, response.Error); - Assert.Equal(SR.FormatID2028(Parameters.RequestId), response.ErrorDescription); - } - - [Fact] - public async Task ExtractEndSessionRequest_InvalidRequestIdParameterIsRejected() - { - // Arrange - await using var server = await CreateServerAsync(options => - { - options.Services.AddDistributedMemoryCache(); - - options.UseOwin() - .EnableEndSessionRequestCaching(); - }); - - await using var client = await server.CreateClientAsync(); - - // Act - var response = await client.PostAsync("/connect/endsession", new OpenIddictRequest - { - RequestId = "EFAF3596-F868-497F-96BB-AA2AD1F8B7E7" - }); - - // Assert - Assert.Equal(Errors.InvalidRequest, response.Error); - Assert.Equal(SR.FormatID2052(Parameters.RequestId), response.ErrorDescription); - } -}