diff --git a/shared/OpenIddict.Extensions/OpenIddictHelpers.cs b/shared/OpenIddict.Extensions/OpenIddictHelpers.cs index 00c5c4cb..87fe62d4 100644 --- a/shared/OpenIddict.Extensions/OpenIddictHelpers.cs +++ b/shared/OpenIddict.Extensions/OpenIddictHelpers.cs @@ -429,13 +429,37 @@ internal static class OpenIddictHelpers return query.TrimStart(Separators.QuestionMark[0]) .Split(new[] { Separators.Ampersand[0], Separators.Semicolon[0] }, StringSplitOptions.RemoveEmptyEntries) - .Select(parameter => parameter.Split(Separators.EqualsSign, StringSplitOptions.RemoveEmptyEntries)) - .Select(parts => ( + .Select(static parameter => parameter.Split(Separators.EqualsSign, StringSplitOptions.RemoveEmptyEntries)) + .Select(static parts => ( Key: parts[0] is string key ? Uri.UnescapeDataString(key) : null, Value: parts.Length > 1 && parts[1] is string value ? Uri.UnescapeDataString(value) : null)) - .Where(pair => !string.IsNullOrEmpty(pair.Key)) - .GroupBy(pair => pair.Key) - .ToDictionary(pair => pair.Key!, pair => new StringValues(pair.Select(parts => parts.Value).ToArray())); + .Where(static pair => !string.IsNullOrEmpty(pair.Key)) + .GroupBy(static pair => pair.Key) + .ToDictionary(static pair => pair.Key!, static pair => new StringValues(pair.Select(parts => parts.Value).ToArray())); + } + + /// + /// Extracts the parameters from the specified fragment. + /// + /// The fragment string, which may start with a '#'. + /// The parameters extracted from the specified fragment. + /// is . + public static IReadOnlyDictionary ParseFragment(string fragment) + { + if (fragment is null) + { + throw new ArgumentNullException(nameof(fragment)); + } + + return fragment.TrimStart(Separators.Hash[0]) + .Split(new[] { Separators.Ampersand[0], Separators.Semicolon[0] }, StringSplitOptions.RemoveEmptyEntries) + .Select(static parameter => parameter.Split(Separators.EqualsSign, StringSplitOptions.RemoveEmptyEntries)) + .Select(static parts => ( + Key: parts[0] is string key ? Uri.UnescapeDataString(key) : null, + Value: parts.Length > 1 && parts[1] is string value ? Uri.UnescapeDataString(value) : null)) + .Where(static pair => !string.IsNullOrEmpty(pair.Key)) + .GroupBy(static pair => pair.Key) + .ToDictionary(static pair => pair.Key!, static pair => new StringValues(pair.Select(parts => parts.Value).ToArray())); } /// diff --git a/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreHandlers.cs b/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreHandlers.cs index 3ba40cd9..5936993f 100644 --- a/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreHandlers.cs +++ b/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreHandlers.cs @@ -51,6 +51,7 @@ public static partial class OpenIddictClientAspNetCoreHandlers ValidateChallengeType.Descriptor, ResolveHostChallengeProperties.Descriptor, ValidateTransportSecurityRequirementForChallenge.Descriptor, + AttachNonDefaultResponseMode.Descriptor, GenerateLoginCorrelationCookie.Descriptor, /* @@ -668,6 +669,98 @@ public static partial class OpenIddictClientAspNetCoreHandlers } } + /// + /// Contains the logic responsible for attaching a non-default response mode to the challenge request. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by ASP.NET Core. + /// + public sealed class AttachNonDefaultResponseMode : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .AddFilter() + .UseSingletonHandler() + .SetOrder(AttachResponseMode.Descriptor.Order - 500) + .Build(); + + /// + public ValueTask HandleAsync(ProcessChallengeContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // If an explicit response type was specified, don't overwrite it. + if (!string.IsNullOrEmpty(context.ResponseMode)) + { + return default; + } + + // Note: in most cases, the query response mode will be used as it offers the best compatibility and, + // unlike the form_post response mode, is compatible with SameSite=Lax cookies (as it uses GET requests + // for the callback stage). However, some specific response_type/response_mode combinations are not + // allowed (e.g query can never be used with a type containing id_token or token, as required by the + // OAuth 2.0 multiple response types specification. To prevent invalid combinations from being sent to + // the remote server, the response types are taken into account when selecting the best response mode. + if (context.ResponseType?.Split(Separators.Space) is not IList { Count: > 0 } types) + { + return default; + } + + context.ResponseMode = ( + // Note: if response modes are explicitly listed in the client registration, only use + // the response modes that are both listed and enabled in the global client options. + // Otherwise, always default to the response modes that have been enabled globally. + SupportedClientResponseModes: context.Registration.ResponseModes.Count switch + { + 0 => context.Options.ResponseModes as ICollection, + _ => context.Options.ResponseModes.Intersect(context.Registration.ResponseModes, StringComparer.Ordinal).ToList() + }, + + SupportedServerResponseModes: context.Configuration.ResponseModesSupported) switch + { + // If both the client and the server support response_mode=form_post, use it if the response + // types contain a value that prevents response_mode=query from being used (token/id_token). + ({ Count: > 0 } client, { Count: > 0 } server) when + client.Contains(ResponseModes.FormPost) && server.Contains(ResponseModes.FormPost) && + (types.Contains(ResponseTypes.IdToken) || types.Contains(ResponseTypes.Token)) + => ResponseModes.FormPost, + + // If the client supports response_mode=form_post and the server doesn't specify a list + // of response modes, assume it is supported and use it if the response types contain + // a value that prevents response_mode=query from being used (token/id_token). + ({ Count: > 0 } client, { Count: 0 }) when client.Contains(ResponseModes.FormPost) && + (types.Contains(ResponseTypes.IdToken) || types.Contains(ResponseTypes.Token)) + => ResponseModes.FormPost, + + // If both the client and the server support response_mode=query, use it. + ({ Count: > 0 } client, { Count: > 0 } server) when + client.Contains(ResponseModes.Query) && server.Contains(ResponseModes.Query) + => ResponseModes.Query, + + // If the client supports response_mode=query and the server doesn't + // specify a list of response modes, assume it is supported. + ({ Count: > 0 } client, { Count: 0 }) when client.Contains(ResponseModes.Query) + => ResponseModes.Query, + + // If both the client and the server support response_mode=form_post, use it. + ({ Count: > 0 } client, { Count: > 0 } server) when + client.Contains(ResponseModes.FormPost) && server.Contains(ResponseModes.FormPost) + => ResponseModes.FormPost, + + // Assign a null value to allow the generic handler present in + // the base client package to negotiate other response modes. + _ => null + }; + + return default; + } + } + /// /// Contains the logic responsible for creating a correlation cookie that serves as a /// protection against state token injection, forged requests and session fixation attacks. diff --git a/src/OpenIddict.Client.Owin/OpenIddictClientOwinHandlers.cs b/src/OpenIddict.Client.Owin/OpenIddictClientOwinHandlers.cs index 5ebfda54..1e270ea0 100644 --- a/src/OpenIddict.Client.Owin/OpenIddictClientOwinHandlers.cs +++ b/src/OpenIddict.Client.Owin/OpenIddictClientOwinHandlers.cs @@ -46,6 +46,7 @@ public static partial class OpenIddictClientOwinHandlers ValidateChallengeType.Descriptor, ResolveHostChallengeProperties.Descriptor, ValidateTransportSecurityRequirementForChallenge.Descriptor, + AttachNonDefaultResponseMode.Descriptor, GenerateLoginCorrelationCookie.Descriptor, /* @@ -695,6 +696,98 @@ public static partial class OpenIddictClientOwinHandlers } } + /// + /// Contains the logic responsible for attaching a non-default response mode to the challenge request. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by OWIN. + /// + public sealed class AttachNonDefaultResponseMode : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .AddFilter() + .UseSingletonHandler() + .SetOrder(AttachResponseMode.Descriptor.Order - 500) + .Build(); + + /// + public ValueTask HandleAsync(ProcessChallengeContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // If an explicit response type was specified, don't overwrite it. + if (!string.IsNullOrEmpty(context.ResponseMode)) + { + return default; + } + + // Note: in most cases, the query response mode will be used as it offers the best compatibility and, + // unlike the form_post response mode, is compatible with SameSite=Lax cookies (as it uses GET requests + // for the callback stage). However, some specific response_type/response_mode combinations are not + // allowed (e.g query can never be used with a type containing id_token or token, as required by the + // OAuth 2.0 multiple response types specification. To prevent invalid combinations from being sent to + // the remote server, the response types are taken into account when selecting the best response mode. + if (context.ResponseType?.Split(Separators.Space) is not IList { Count: > 0 } types) + { + return default; + } + + context.ResponseMode = ( + // Note: if response modes are explicitly listed in the client registration, only use + // the response modes that are both listed and enabled in the global client options. + // Otherwise, always default to the response modes that have been enabled globally. + SupportedClientResponseModes: context.Registration.ResponseModes.Count switch + { + 0 => context.Options.ResponseModes as ICollection, + _ => context.Options.ResponseModes.Intersect(context.Registration.ResponseModes, StringComparer.Ordinal).ToList() + }, + + SupportedServerResponseModes: context.Configuration.ResponseModesSupported) switch + { + // If both the client and the server support response_mode=form_post, use it if the response + // types contain a value that prevents response_mode=query from being used (token/id_token). + ({ Count: > 0 } client, { Count: > 0 } server) when + client.Contains(ResponseModes.FormPost) && server.Contains(ResponseModes.FormPost) && + (types.Contains(ResponseTypes.IdToken) || types.Contains(ResponseTypes.Token)) + => ResponseModes.FormPost, + + // If the client supports response_mode=form_post and the server doesn't specify a list + // of response modes, assume it is supported and use it if the response types contain + // a value that prevents response_mode=query from being used (token/id_token). + ({ Count: > 0 } client, { Count: 0 }) when client.Contains(ResponseModes.FormPost) && + (types.Contains(ResponseTypes.IdToken) || types.Contains(ResponseTypes.Token)) + => ResponseModes.FormPost, + + // If both the client and the server support response_mode=query, use it. + ({ Count: > 0 } client, { Count: > 0 } server) when + client.Contains(ResponseModes.Query) && server.Contains(ResponseModes.Query) + => ResponseModes.Query, + + // If the client supports response_mode=query and the server doesn't + // specify a list of response modes, assume it is supported. + ({ Count: > 0 } client, { Count: 0 }) when client.Contains(ResponseModes.Query) + => ResponseModes.Query, + + // If both the client and the server support response_mode=form_post, use it. + ({ Count: > 0 } client, { Count: > 0 } server) when + client.Contains(ResponseModes.FormPost) && server.Contains(ResponseModes.FormPost) + => ResponseModes.FormPost, + + // Assign a null value to allow the generic handler present in + // the base client package to negotiate other response modes. + _ => null + }; + + return default; + } + } + /// /// Contains the logic responsible for creating a correlation cookie that serves as a /// protection against state token injection, forged requests and session fixation attacks. diff --git a/src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationHandlers.cs b/src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationHandlers.cs index 50b7889f..56a99bfd 100644 --- a/src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationHandlers.cs +++ b/src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationHandlers.cs @@ -8,6 +8,7 @@ using System.Collections.Immutable; using System.ComponentModel; using System.Diagnostics; using System.Net; +using System.Runtime.CompilerServices; using System.Runtime.Versioning; using System.Security.Claims; using System.Text; @@ -75,6 +76,7 @@ public static partial class OpenIddictClientSystemIntegrationHandlers */ InferBaseUriFromClientUri.Descriptor, AttachDynamicPortToRedirectUri.Descriptor, + AttachNonDefaultResponseMode.Descriptor, AttachInstanceIdentifier.Descriptor, TrackAuthenticationOperation.Descriptor, @@ -537,12 +539,34 @@ public static partial class OpenIddictClientSystemIntegrationHandlers throw new ArgumentNullException(nameof(context)); } - context.Transaction.Request = context.Transaction.GetProtocolActivation() switch + if (context.Transaction.GetProtocolActivation() is not { ActivationUri: Uri uri }) { - { ActivationUri: Uri uri } => new OpenIddictRequest(OpenIddictHelpers.ParseQuery(uri.Query)), + throw new InvalidOperationException(SR.GetResourceString(SR.ID0375)); + } - _ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0375)) - }; + var parameters = new Dictionary(StringComparer.Ordinal); + + if (!string.IsNullOrEmpty(uri.Query)) + { + foreach (var parameter in OpenIddictHelpers.ParseQuery(uri.Query)) + { + parameters[parameter.Key] = parameter.Value; + } + } + + // Note: the fragment is always processed after the query string to ensure that + // parameters extracted from the fragment are preferred to parameters extracted + // from the query string when they are present in both parts. + + if (!string.IsNullOrEmpty(uri.Fragment)) + { + foreach (var parameter in OpenIddictHelpers.ParseFragment(uri.Fragment)) + { + parameters[parameter.Key] = parameter.Value; + } + } + + context.Transaction.Request = new OpenIddictRequest(parameters); return default; } @@ -576,14 +600,34 @@ public static partial class OpenIddictClientSystemIntegrationHandlers } #if SUPPORTS_WINDOWS_RUNTIME - context.Transaction.Request = context.Transaction.GetWebAuthenticationResult() switch + if (context.Transaction.GetWebAuthenticationResult() + is not { ResponseStatus: WebAuthenticationStatus.Success, ResponseData: string data } || + !Uri.TryCreate(data, UriKind.Absolute, out Uri? uri)) { - { ResponseStatus: WebAuthenticationStatus.Success, ResponseData: string data } when - Uri.TryCreate(data, UriKind.Absolute, out Uri? uri) - => new OpenIddictRequest(OpenIddictHelpers.ParseQuery(uri.Query)), + throw new InvalidOperationException(SR.GetResourceString(SR.ID0393)); + } - _ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0393)) - }; + var parameters = new Dictionary(StringComparer.Ordinal); + + if (!string.IsNullOrEmpty(uri.Query)) + { + foreach (var parameter in OpenIddictHelpers.ParseQuery(uri.Query)) + { + parameters[parameter.Key] = parameter.Value; + } + } + + // Note: the fragment is always processed after the query string to ensure that + // parameters extracted from the fragment are preferred to parameters extracted + // from the query string when they are present in both parts. + + if (!string.IsNullOrEmpty(uri.Fragment)) + { + foreach (var parameter in OpenIddictHelpers.ParseFragment(uri.Fragment)) + { + parameters[parameter.Key] = parameter.Value; + } + } return default; #else @@ -1679,7 +1723,7 @@ public static partial class OpenIddictClientSystemIntegrationHandlers .AddFilter() // Note: only apply the dynamic port replacement logic if the callback request // is going to be received by the system browser to ensure it doesn't apply to - // challenge demands handled via a web authentication broker are not affected. + // challenge demands handled via a web authentication broker. .AddFilter() .UseSingletonHandler() .SetOrder(AttachRedirectUri.Descriptor.Order + 500) @@ -1713,6 +1757,162 @@ public static partial class OpenIddictClientSystemIntegrationHandlers } } + /// + /// Contains the logic responsible for attaching a non-default response mode to the challenge request, if applicable. + /// + public sealed class AttachNonDefaultResponseMode : IOpenIddictClientHandler + { + private readonly OpenIddictClientSystemIntegrationHttpListener _listener; + private readonly IOptionsMonitor _options; + + public AttachNonDefaultResponseMode( + OpenIddictClientSystemIntegrationHttpListener listener, + IOptionsMonitor options) + { + _listener = listener ?? throw new ArgumentNullException(nameof(listener)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + } + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .AddFilter() + .UseSingletonHandler() + .SetOrder(AttachResponseMode.Descriptor.Order - 500) + .Build(); + + /// + public async ValueTask HandleAsync(ProcessChallengeContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // If an explicit response type was specified, don't overwrite it. + if (!string.IsNullOrEmpty(context.ResponseMode)) + { + return; + } + + // Some specific response_type/response_mode combinations are not allowed (e.g response_mode=query + // can never be used with a response type containing id_token or token, as required by the OAuth 2.0 + // multiple response types specification. To prevent invalid combinations from being sent to the + // remote server, the response types are taken into account when selecting the best response mode. + if (context.ResponseType?.Split(Separators.Space) is not IList { Count: > 0 } types) + { + return; + } + + context.ResponseMode = ( + // Note: if response modes are explicitly listed in the client registration, only use + // the response modes that are both listed and enabled in the global client options. + // Otherwise, always default to the response modes that have been enabled globally. + SupportedClientResponseModes: context.Registration.ResponseModes.Count switch + { + 0 => context.Options.ResponseModes as ICollection, + _ => context.Options.ResponseModes.Intersect(context.Registration.ResponseModes, StringComparer.Ordinal).ToList() + }, + + SupportedServerResponseModes: context.Configuration.ResponseModesSupported) switch + { +#if SUPPORTS_WINDOWS_RUNTIME + // When using the web authentication broker on Windows, if both the client and + // the server support response_mode=fragment, use it if the response types contain + // a value that prevents response_mode=query from being used (token/id_token). + ({ Count: > 0 } client, { Count: > 0 } server) when + OpenIddictClientSystemIntegrationHelpers.IsWebAuthenticationBrokerSupported() && + IsAuthenticationMode(OpenIddictClientSystemIntegrationAuthenticationMode.WebAuthenticationBroker) && + client.Contains(ResponseModes.Fragment) && server.Contains(ResponseModes.Fragment) && + (types.Contains(ResponseTypes.IdToken) || types.Contains(ResponseTypes.Token)) + => ResponseModes.Fragment, + + // When using the web authentication broker on Windows, if the client supports + // response_mode=fragment and the server doesn't specify a list of response modes, + // assume it is supported and use it if the response types contain a value that + // prevents response_mode=query from being used (token/id_token). + ({ Count: > 0 } client, { Count: 0 }) when + OpenIddictClientSystemIntegrationHelpers.IsWebAuthenticationBrokerSupported() && + IsAuthenticationMode(OpenIddictClientSystemIntegrationAuthenticationMode.WebAuthenticationBroker) && + client.Contains(ResponseModes.Fragment) && + (types.Contains(ResponseTypes.IdToken) || types.Contains(ResponseTypes.Token)) + => ResponseModes.Fragment, +#endif + // When using browser-based authentication with a redirect_uri not pointing to the embedded server, + // if both the client and the server support response_mode=fragment, use it if the response types + // contain a value that prevents response_mode=query from being used (token/id_token). + ({ Count: > 0 } client, { Count: > 0 } server) when + IsAuthenticationMode(OpenIddictClientSystemIntegrationAuthenticationMode.SystemBrowser) && + Uri.TryCreate(context.RedirectUri, UriKind.Absolute, out Uri? uri) && + !string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && + uri.Port != await _listener.GetEmbeddedServerPortAsync(context.CancellationToken) && + client.Contains(ResponseModes.Fragment) && server.Contains(ResponseModes.Fragment) && + (types.Contains(ResponseTypes.IdToken) || types.Contains(ResponseTypes.Token)) + => ResponseModes.Fragment, + + // When using browser-based authentication with a redirect_uri not pointing to the embedded server, + // if the client supports response_mode=fragment and the server doesn't specify a list of response + // modes, assume it is supported and use it if the response types contain a value that prevents + // response_mode=query from being used (token/id_token). + ({ Count: > 0 } client, { Count: 0 }) when + IsAuthenticationMode(OpenIddictClientSystemIntegrationAuthenticationMode.SystemBrowser) && + Uri.TryCreate(context.RedirectUri, UriKind.Absolute, out Uri? uri) && + !string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && + uri.Port != await _listener.GetEmbeddedServerPortAsync(context.CancellationToken) && + client.Contains(ResponseModes.Fragment) && + (types.Contains(ResponseTypes.IdToken) || types.Contains(ResponseTypes.Token)) + => ResponseModes.Fragment, + + // When using browser-based authentication with a redirect_uri pointing to the embedded server, + // if both the client and the server support response_mode=form_post, use it if the response + // types contain a value that prevents response_mode=query from being used (token/id_token). + ({ Count: > 0 } client, { Count: > 0 } server) when + IsAuthenticationMode(OpenIddictClientSystemIntegrationAuthenticationMode.SystemBrowser) && + Uri.TryCreate(context.RedirectUri, UriKind.Absolute, out Uri? uri) && + string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && + uri.IsLoopback && + uri.Port == await _listener.GetEmbeddedServerPortAsync(context.CancellationToken) && + client.Contains(ResponseModes.FormPost) && server.Contains(ResponseModes.FormPost) && + (types.Contains(ResponseTypes.IdToken) || types.Contains(ResponseTypes.Token)) + => ResponseModes.FormPost, + + // When using browser-based authentication with a redirect_uri pointing to the embedded server, + // if the client supports response_mode=form_post and the server doesn't specify a list + // of response modes, assume it is supported and use it if the response types contain + // a value that prevents response_mode=query from being used (token/id_token). + ({ Count: > 0 } client, { Count: 0 }) when + IsAuthenticationMode(OpenIddictClientSystemIntegrationAuthenticationMode.SystemBrowser) && + Uri.TryCreate(context.RedirectUri, UriKind.Absolute, out Uri? uri) && + string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && + uri.IsLoopback && + uri.Port == await _listener.GetEmbeddedServerPortAsync(context.CancellationToken) && + client.Contains(ResponseModes.FormPost) && + (types.Contains(ResponseTypes.IdToken) || types.Contains(ResponseTypes.Token)) + => ResponseModes.FormPost, + + // Assign a null value to allow the generic handler present in + // the base client package to negotiate other response modes. + _ => null + }; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + bool IsAuthenticationMode(OpenIddictClientSystemIntegrationAuthenticationMode mode) + { + if (context.Transaction.Properties.TryGetValue( + typeof(OpenIddictClientSystemIntegrationAuthenticationMode).FullName!, out var result) && + result is OpenIddictClientSystemIntegrationAuthenticationMode value) + { + return mode == value; + } + + return mode == _options.CurrentValue.AuthenticationMode; + } + } + } + /// /// Contains the logic responsible for storing the identifier of the current instance in the state token. /// Note: this handler is not used when the user session is not interactive. diff --git a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.cs b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.cs index 840643e5..1c1d4e6d 100644 --- a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.cs +++ b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.cs @@ -1468,9 +1468,7 @@ public static partial class OpenIddictClientWebIntegrationHandlers = OpenIddictClientHandlerDescriptor.CreateBuilder() .AddFilter() .UseSingletonHandler() - // Note: this handler MUST be invoked after the scopes have been attached to the - // context to support overriding the response mode based on the requested scopes. - .SetOrder(AttachScopes.Descriptor.Order + 500) + .SetOrder(AttachResponseMode.Descriptor.Order + 500) .SetType(OpenIddictClientHandlerType.BuiltIn) .Build(); diff --git a/src/OpenIddict.Client/OpenIddictClientHandlers.cs b/src/OpenIddict.Client/OpenIddictClientHandlers.cs index acdc7c34..d0642518 100644 --- a/src/OpenIddict.Client/OpenIddictClientHandlers.cs +++ b/src/OpenIddict.Client/OpenIddictClientHandlers.cs @@ -107,13 +107,13 @@ public static partial class OpenIddictClientHandlers AttachGrantTypeAndResponseType.Descriptor, EvaluateGeneratedChallengeTokens.Descriptor, AttachChallengeHostProperties.Descriptor, - AttachResponseMode.Descriptor, AttachClientId.Descriptor, AttachRedirectUri.Descriptor, AttachRequestForgeryProtection.Descriptor, AttachScopes.Descriptor, AttachNonce.Descriptor, AttachCodeChallengeParameters.Descriptor, + AttachResponseMode.Descriptor, PrepareLoginStateTokenPrincipal.Descriptor, GenerateLoginStateToken.Descriptor, AttachChallengeParameters.Descriptor, @@ -4569,7 +4569,7 @@ public static partial class OpenIddictClientHandlers // Hybrid flow with grant_type=authorization_code/implicit and response_type=code id_token: (var client, var server) when // Ensure grant_type=authorization_code and grant_type=implicit are supported. - (client.GrantTypes.Contains(GrantTypes.AuthorizationCode) && client.GrantTypes.Contains(GrantTypes.Implicit)) && + (client.GrantTypes.Contains(GrantTypes.AuthorizationCode) && client.GrantTypes.Contains(GrantTypes.Implicit)) && (server.GrantTypes.Count is 0 || // If empty, assume the code and implicit grants are supported by the server. (server.GrantTypes.Contains(GrantTypes.AuthorizationCode) && server.GrantTypes.Contains(GrantTypes.Implicit))) && @@ -4606,7 +4606,7 @@ public static partial class OpenIddictClientHandlers // Hybrid flow with grant_type=authorization_code/implicit and response_type=code id_token token. (var client, var server) when // Ensure grant_type=authorization_code and grant_type=implicit are supported. - (client.GrantTypes.Contains(GrantTypes.AuthorizationCode) && client.GrantTypes.Contains(GrantTypes.Implicit)) && + (client.GrantTypes.Contains(GrantTypes.AuthorizationCode) && client.GrantTypes.Contains(GrantTypes.Implicit)) && (server.GrantTypes.Count is 0 || // If empty, assume the code and implicit grants are supported by the server. (server.GrantTypes.Contains(GrantTypes.AuthorizationCode) && server.GrantTypes.Contains(GrantTypes.Implicit))) && @@ -4623,7 +4623,7 @@ public static partial class OpenIddictClientHandlers // Hybrid flow with grant_type=authorization_code/implicit and response_type=code token. (var client, var server) when // Ensure grant_type=authorization_code and grant_type=implicit are supported. - (client.GrantTypes.Contains(GrantTypes.AuthorizationCode) && client.GrantTypes.Contains(GrantTypes.Implicit)) && + (client.GrantTypes.Contains(GrantTypes.AuthorizationCode) && client.GrantTypes.Contains(GrantTypes.Implicit)) && (server.GrantTypes.Count is 0 || // If empty, assume the code and implicit grants are supported by the server. (server.GrantTypes.Contains(GrantTypes.AuthorizationCode) && server.GrantTypes.Contains(GrantTypes.Implicit))) && @@ -4762,109 +4762,6 @@ public static partial class OpenIddictClientHandlers } } - /// - /// Contains the logic responsible for attaching the response mode to the challenge request. - /// - public sealed class AttachResponseMode : IOpenIddictClientHandler - { - /// - /// Gets the default descriptor definition assigned to this handler. - /// - public static OpenIddictClientHandlerDescriptor Descriptor { get; } - = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() - .UseSingletonHandler() - .SetOrder(AttachChallengeHostProperties.Descriptor.Order + 1_000) - .Build(); - - /// - public ValueTask HandleAsync(ProcessChallengeContext context) - { - if (context is null) - { - throw new ArgumentNullException(nameof(context)); - } - - // If an explicit response type was specified, don't overwrite it. - if (!string.IsNullOrEmpty(context.ResponseMode)) - { - return default; - } - - // Note: in most cases, the query response mode will be used as it offers the - // best compatibility and, unlike the form_post response mode, is compatible - // with SameSite=Lax cookies (as it uses GET requests for the callback stage). - // - // However, browser-based hosts like Blazor may typically want to use the fragment - // response mode as it offers a better protection for SPA applications. - // Unfortunately, server-side clients like ASP.NET Core applications cannot - // natively use response_mode=fragment as URI fragments are never sent to servers. - // - // As such, this handler will not choose response_mode=fragment by default and it is - // expected that specialized hosts like Blazor implement custom event handlers to - // opt for fragment by default, if it is supported by the authorization server. - - // Some specific response_type/response_mode combinations are not allowed (e.g response_mode=query - // can never be used with a response type containing id_token or token, as required by the OAuth 2.0 - // multiple response types specification. To prevent invalid combinations from being sent to the - // remote server, the response types are taken into account when selecting the best response mode. - if (context.ResponseType?.Split(Separators.Space) is not IList { Count: > 0 } types) - { - return default; - } - - context.ResponseMode = ( - // Note: if response modes are explicitly listed in the client registration, only use - // the response modes that are both listed and enabled in the global client options. - // Otherwise, always default to the response modes that have been enabled globally. - SupportedClientResponseModes: context.Registration.ResponseModes.Count switch - { - 0 => context.Options.ResponseModes as ICollection, - _ => context.Options.ResponseModes.Intersect(context.Registration.ResponseModes, StringComparer.Ordinal).ToList() - }, - - SupportedServerResponseModes: context.Configuration.ResponseModesSupported) switch - { - // If the list of response modes supported by the client is empty, abort the challenge operation. - ({ Count: 0 }, { Count: _ }) => throw new InvalidOperationException(SR.GetResourceString(SR.ID0362)), - - // If both the client and the server support response_mode=form_post, use it if the response - // types contain a value that prevents response_mode=query from being used (token/id_token). - ({ Count: > 0 } client, { Count: > 0 } server) when - client.Contains(ResponseModes.FormPost) && server.Contains(ResponseModes.FormPost) && - (types.Contains(ResponseTypes.IdToken) || types.Contains(ResponseTypes.Token)) - => ResponseModes.FormPost, - - // If the client support response_mode=form_post and the server doesn't specify a list - // of response modes, assume it is supported and use it if the response types contain - // a value that prevents response_mode=query from being used (token/id_token). - ({ Count: > 0 } client, { Count: 0 }) when client.Contains(ResponseModes.FormPost) && - (types.Contains(ResponseTypes.IdToken) || types.Contains(ResponseTypes.Token)) - => ResponseModes.FormPost, - - // If both the client and the server support response_mode=query, use it. - ({ Count: > 0 } client, { Count: > 0 } server) when - client.Contains(ResponseModes.Query) && server.Contains(ResponseModes.Query) - => ResponseModes.Query, - - // If the client support response_mode=query and the server doesn't - // specify a list of response modes, assume it is supported. - ({ Count: > 0 } client, { Count: 0 }) when client.Contains(ResponseModes.Query) - => ResponseModes.Query, - - // If both the client and the server support response_mode=form_post, use it. - ({ Count: > 0 } client, { Count: > 0 } server) when - client.Contains(ResponseModes.FormPost) && server.Contains(ResponseModes.FormPost) - => ResponseModes.FormPost, - - // If no common response mode can be negotiated, abort the challenge operation. - _ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0299)) - }; - - return default; - } - } - /// /// Contains the logic responsible for attaching the client identifier to the challenge request. /// @@ -4876,7 +4773,7 @@ public static partial class OpenIddictClientHandlers public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() .UseSingletonHandler() - .SetOrder(AttachResponseMode.Descriptor.Order + 1_000) + .SetOrder(AttachChallengeHostProperties.Descriptor.Order + 1_000) .Build(); /// @@ -5176,6 +5073,72 @@ public static partial class OpenIddictClientHandlers } } + /// + /// Contains the logic responsible for attaching the response mode to the challenge request. + /// + public sealed class AttachResponseMode : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(AttachCodeChallengeParameters.Descriptor.Order + 1_000) + .Build(); + + /// + public ValueTask HandleAsync(ProcessChallengeContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // If an explicit response type was specified, don't overwrite it. + if (!string.IsNullOrEmpty(context.ResponseMode)) + { + return default; + } + + context.ResponseMode = ( + // Note: if response modes are explicitly listed in the client registration, only use + // the response modes that are both listed and enabled in the global client options. + // Otherwise, always default to the response modes that have been enabled globally. + SupportedClientResponseModes: context.Registration.ResponseModes.Count switch + { + 0 => context.Options.ResponseModes as ICollection, + _ => context.Options.ResponseModes.Intersect(context.Registration.ResponseModes, StringComparer.Ordinal).ToList() + }, + + SupportedServerResponseModes: context.Configuration.ResponseModesSupported) switch + { + // If the list of response modes supported by the client is empty, abort the challenge operation. + ({ Count: 0 }, { Count: _ }) => throw new InvalidOperationException(SR.GetResourceString(SR.ID0362)), + + // If both the client and the server support response_mode=query, use it. + ({ Count: > 0 } client, { Count: > 0 } server) when + client.Contains(ResponseModes.Query) && server.Contains(ResponseModes.Query) + => ResponseModes.Query, + + // If the client supports response_mode=query and the server doesn't + // specify a list of response modes, assume it is supported. + ({ Count: > 0 } client, { Count: 0 }) when client.Contains(ResponseModes.Query) + => ResponseModes.Query, + + // Note: other response modes - like form_post or fragment - are never negotiated + // by this generic handler but can be selected by more specialized handlers, such + // as the one present in the ASP.NET Core/OWIN hosts or in the system integration. + + // If no common response mode can be negotiated, abort the challenge operation. + _ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0299)) + }; + + return default; + } + } + /// /// Contains the logic responsible for preparing and attaching the claims principal /// used to generate the state token, if one is going to be returned. @@ -5189,7 +5152,7 @@ public static partial class OpenIddictClientHandlers = OpenIddictClientHandlerDescriptor.CreateBuilder() .AddFilter() .UseSingletonHandler() - .SetOrder(AttachCodeChallengeParameters.Descriptor.Order + 1_000) + .SetOrder(AttachResponseMode.Descriptor.Order + 1_000) .SetType(OpenIddictClientHandlerType.BuiltIn) .Build();