diff --git a/.editorconfig b/.editorconfig index 402c9ed2a..27ad13512 100644 --- a/.editorconfig +++ b/.editorconfig @@ -97,6 +97,7 @@ csharp_using_directive_placement = outside_namespace dotnet_code_quality_unused_parameters = all dotnet_diagnostic.CA1510.severity = none dotnet_diagnostic.CA2254.severity = none +dotnet_diagnostic.IDE0002.severity = none dotnet_diagnostic.IDE0305.severity = none dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i diff --git a/sandbox/OpenIddict.Sandbox.AspNet.Server/Controllers/AuthorizationController.cs b/sandbox/OpenIddict.Sandbox.AspNet.Server/Controllers/AuthorizationController.cs index ed0880da1..5b2bd22a0 100644 --- a/sandbox/OpenIddict.Sandbox.AspNet.Server/Controllers/AuthorizationController.cs +++ b/sandbox/OpenIddict.Sandbox.AspNet.Server/Controllers/AuthorizationController.cs @@ -139,7 +139,7 @@ public async Task Authorize() // return an authorization response without displaying the consent form. case ConsentTypes.Implicit: case ConsentTypes.External when authorizations.Count is not 0: - case ConsentTypes.Explicit when authorizations.Count is not 0 && !request.HasPrompt(Prompts.Consent): + case ConsentTypes.Explicit when authorizations.Count is not 0 && !request.HasPrompt(PromptValues.Consent): // Create the claims-based identity that will be used by OpenIddict to generate tokens. var identity = new ClaimsIdentity( authenticationType: OpenIddictServerOwinDefaults.AuthenticationType, @@ -178,8 +178,8 @@ public async Task Authorize() // At this point, no authorization was found in the database and an error must be returned // if the client application specified prompt=none in the authorization request. - case ConsentTypes.Explicit when request.HasPrompt(Prompts.None): - case ConsentTypes.Systematic when request.HasPrompt(Prompts.None): + case ConsentTypes.Explicit when request.HasPrompt(PromptValues.None): + case ConsentTypes.Systematic when request.HasPrompt(PromptValues.None): context.Authentication.Challenge( authenticationTypes: OpenIddictServerOwinDefaults.AuthenticationType, properties: new AuthenticationProperties(new Dictionary diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Controllers/AuthorizationController.cs b/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Controllers/AuthorizationController.cs index 418042314..eafea9965 100644 --- a/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Controllers/AuthorizationController.cs +++ b/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Controllers/AuthorizationController.cs @@ -71,13 +71,13 @@ public async Task Authorize() // For scenarios where the default authentication handler configured in the ASP.NET Core // authentication options shouldn't be used, a specific scheme can be specified here. var result = await HttpContext.AuthenticateAsync(); - if (result == null || !result.Succeeded || request.HasPrompt(Prompts.Login) || + if (result == null || !result.Succeeded || request.HasPrompt(PromptValues.Login) || (request.MaxAge != null && result.Properties?.IssuedUtc != null && DateTimeOffset.UtcNow - result.Properties.IssuedUtc > TimeSpan.FromSeconds(request.MaxAge.Value))) { // If the client application requested promptless authentication, // return an error indicating that the user is not logged in. - if (request.HasPrompt(Prompts.None)) + if (request.HasPrompt(PromptValues.None)) { return Forbid( authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, @@ -90,7 +90,7 @@ public async Task Authorize() // To avoid endless login -> authorization redirects, the prompt=login flag // is removed from the authorization request payload before redirecting the user. - var prompt = string.Join(" ", request.GetPrompts().Remove(Prompts.Login)); + var prompt = string.Join(" ", request.GetPrompts().Remove(PromptValues.Login)); var parameters = Request.HasFormContentType ? Request.Form.Where(parameter => parameter.Key != Parameters.Prompt).ToList() : @@ -173,7 +173,7 @@ public async Task Authorize() // return an authorization response without displaying the consent form. case ConsentTypes.Implicit: case ConsentTypes.External when authorizations.Count is not 0: - case ConsentTypes.Explicit when authorizations.Count is not 0 && !request.HasPrompt(Prompts.Consent): + case ConsentTypes.Explicit when authorizations.Count is not 0 && !request.HasPrompt(PromptValues.Consent): // Create the claims-based identity that will be used by OpenIddict to generate tokens. var identity = new ClaimsIdentity( authenticationType: TokenValidationParameters.DefaultAuthenticationType, @@ -210,8 +210,8 @@ public async Task Authorize() // At this point, no authorization was found in the database and an error must be returned // if the client application specified prompt=none in the authorization request. - case ConsentTypes.Explicit when request.HasPrompt(Prompts.None): - case ConsentTypes.Systematic when request.HasPrompt(Prompts.None): + case ConsentTypes.Explicit when request.HasPrompt(PromptValues.None): + case ConsentTypes.Systematic when request.HasPrompt(PromptValues.None): return Forbid( authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, properties: new AuthenticationProperties(new Dictionary diff --git a/src/OpenIddict.Abstractions/OpenIddictConstants.cs b/src/OpenIddict.Abstractions/OpenIddictConstants.cs index d53e17e40..ed71631b8 100644 --- a/src/OpenIddict.Abstractions/OpenIddictConstants.cs +++ b/src/OpenIddict.Abstractions/OpenIddictConstants.cs @@ -295,6 +295,7 @@ public static class Metadata public const string MtlsEndpointAliases = "mtls_endpoint_aliases"; public const string OpPolicyUri = "op_policy_uri"; public const string OpTosUri = "op_tos_uri"; + public const string PromptValuesSupported = "prompt_values_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"; @@ -430,9 +431,10 @@ public static class Scopes } } - public static class Prompts + public static class PromptValues { public const string Consent = "consent"; + public const string Create = "create"; public const string Login = "login"; public const string None = "none"; public const string SelectAccount = "select_account"; diff --git a/src/OpenIddict.Abstractions/OpenIddictResources.resx b/src/OpenIddict.Abstractions/OpenIddictResources.resx index 3fa78acae..0ab3e2479 100644 --- a/src/OpenIddict.Abstractions/OpenIddictResources.resx +++ b/src/OpenIddict.Abstractions/OpenIddictResources.resx @@ -365,12 +365,6 @@ Consider using 'options.AddSigningCredentials(SigningCredentials)' instead. Endpoint URIs must be valid URIs. - - Claims cannot be null or empty. - - - Scopes cannot be null or empty. - The security token handler cannot be null. @@ -1704,6 +1698,9 @@ To apply post-logout redirection responses, create a class implementing 'IOpenId The specified client authentication method/token binding methods combination is not valid. + + The '{0}' parameter cannot contain null or empty values. + The security token is missing. @@ -2379,7 +2376,7 @@ The principal used to create the token contained the following claims: {Claims}. The authorization request was rejected because the '{Scope}' scope was missing. - The authorization request was rejected because an invalid prompt parameter was specified. + The authorization request was rejected because an invalid prompt combination was specified. The authorization request was rejected because the specified code challenge method was not supported. @@ -2892,6 +2889,9 @@ This may indicate that the hashed entry is corrupted or malformed. An error was returned by ASWebAuthenticationSession while trying to start a sign-out operation. + + The authorization request was rejected because an unsupported prompt parameter was specified. + https://documentation.openiddict.com/errors/{0} diff --git a/src/OpenIddict.Server/OpenIddictServerBuilder.cs b/src/OpenIddict.Server/OpenIddictServerBuilder.cs index 2e70a6be5..ab6c73c8f 100644 --- a/src/OpenIddict.Server/OpenIddictServerBuilder.cs +++ b/src/OpenIddict.Server/OpenIddictServerBuilder.cs @@ -1634,12 +1634,33 @@ public OpenIddictServerBuilder RegisterClaims(params string[] claims) if (Array.Exists(claims, string.IsNullOrEmpty)) { - throw new ArgumentException(SR.GetResourceString(SR.ID0073), nameof(claims)); + throw new ArgumentException(SR.FormatID0457(nameof(claims)), nameof(claims)); } return Configure(options => options.Claims.UnionWith(claims)); } + /// + /// Registers the specified prompt values as supported scopes so + /// they can be returned as part of the discovery document. + /// + /// The supported prompt values. + /// The instance. + public OpenIddictServerBuilder RegisterPromptValues(params string[] values) + { + if (values is null) + { + throw new ArgumentNullException(nameof(values)); + } + + if (Array.Exists(values, string.IsNullOrEmpty)) + { + throw new ArgumentException(SR.FormatID0457(nameof(values)), nameof(values)); + } + + return Configure(options => options.PromptValues.UnionWith(values)); + } + /// /// Registers the specified scopes as supported scopes so /// they can be returned as part of the discovery document. @@ -1655,7 +1676,7 @@ public OpenIddictServerBuilder RegisterScopes(params string[] scopes) if (Array.Exists(scopes, string.IsNullOrEmpty)) { - throw new ArgumentException(SR.GetResourceString(SR.ID0074), nameof(scopes)); + throw new ArgumentException(SR.FormatID0457(nameof(scopes)), nameof(scopes)); } return Configure(options => options.Scopes.UnionWith(scopes)); diff --git a/src/OpenIddict.Server/OpenIddictServerEvents.Discovery.cs b/src/OpenIddict.Server/OpenIddictServerEvents.Discovery.cs index bcc16fb34..7370a7c54 100644 --- a/src/OpenIddict.Server/OpenIddictServerEvents.Discovery.cs +++ b/src/OpenIddict.Server/OpenIddictServerEvents.Discovery.cs @@ -166,6 +166,11 @@ public OpenIddictRequest Request /// public HashSet IntrospectionEndpointAuthenticationMethods { get; } = new(StringComparer.Ordinal); + /// + /// Gets the list of prompt values supported by the authorization server. + /// + public HashSet PromptValues { get; } = new(StringComparer.Ordinal); + /// /// Gets the list of response modes /// supported by the authorization server. diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs index 76d9de706..2dd3dcae2 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs @@ -875,10 +875,33 @@ public ValueTask HandleAsync(ValidateAuthorizationRequestContext context) 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.GetPrompts().ToHashSet(StringComparer.Ordinal)) + { + if (!context.Options.PromptValues.Contains(value)) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6233)); + + 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. - if (context.Request.HasPrompt(Prompts.None) && (context.Request.HasPrompt(Prompts.Consent) || - context.Request.HasPrompt(Prompts.Login) || - context.Request.HasPrompt(Prompts.SelectAccount))) + // See https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest for more information. + if (context.Request.HasPrompt(PromptValues.None) && (context.Request.HasPrompt(PromptValues.Consent) || + context.Request.HasPrompt(PromptValues.Login) || + context.Request.HasPrompt(PromptValues.SelectAccount))) { context.Logger.LogInformation(SR.GetResourceString(SR.ID6040)); diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Discovery.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Discovery.cs index c6a34a716..c6cea2485 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Discovery.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Discovery.cs @@ -41,6 +41,7 @@ public static class Discovery AttachScopes.Descriptor, AttachClaims.Descriptor, AttachSubjectTypes.Descriptor, + AttachPromptValues.Descriptor, AttachSigningAlgorithms.Descriptor, AttachAdditionalMetadata.Descriptor, @@ -250,6 +251,7 @@ public async ValueTask HandleAsync(ProcessRequestContext context) [Metadata.IdTokenSigningAlgValuesSupported] = notification.IdTokenSigningAlgorithms.ToArray(), [Metadata.CodeChallengeMethodsSupported] = notification.CodeChallengeMethods.ToArray(), [Metadata.SubjectTypesSupported] = notification.SubjectTypes.ToArray(), + [Metadata.PromptValuesSupported] = notification.PromptValues.ToArray(), [Metadata.TokenEndpointAuthMethodsSupported] = notification.TokenEndpointAuthenticationMethods.ToArray(), [Metadata.IntrospectionEndpointAuthMethodsSupported] = notification.IntrospectionEndpointAuthenticationMethods.ToArray(), [Metadata.RevocationEndpointAuthMethodsSupported] = notification.RevocationEndpointAuthenticationMethods.ToArray(), @@ -673,6 +675,35 @@ public ValueTask HandleAsync(HandleConfigurationRequestContext context) } } + /// + /// Contains the logic responsible for attaching the supported prompt values to the provider discovery document. + /// + public sealed class AttachPromptValues : IOpenIddictServerHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(AttachSubjectTypes.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(HandleConfigurationRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + context.PromptValues.UnionWith(context.Options.PromptValues); + + return default; + } + } + /// /// Contains the logic responsible for attaching the supported signing algorithms to the provider discovery document. /// @@ -684,7 +715,7 @@ public sealed class AttachSigningAlgorithms : IOpenIddictServerHandler() .UseSingletonHandler() - .SetOrder(AttachSubjectTypes.Descriptor.Order + 1_000) + .SetOrder(AttachPromptValues.Descriptor.Order + 1_000) .SetType(OpenIddictServerHandlerType.BuiltIn) .Build(); diff --git a/src/OpenIddict.Server/OpenIddictServerOptions.cs b/src/OpenIddict.Server/OpenIddictServerOptions.cs index a4a90bde9..0077fb546 100644 --- a/src/OpenIddict.Server/OpenIddictServerOptions.cs +++ b/src/OpenIddict.Server/OpenIddictServerOptions.cs @@ -375,6 +375,19 @@ public sealed class OpenIddictServerOptions /// public HashSet GrantTypes { get; } = new(StringComparer.Ordinal); + /// + /// Gets the OpenID Connect prompt values enabled for this application. + /// + public HashSet PromptValues { get; } = new(StringComparer.Ordinal) + { + // By default, only include the mandatory values defined in the core OpenID Connect specification. + // See https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest for more information. + OpenIddictConstants.PromptValues.Consent, + OpenIddictConstants.PromptValues.Login, + OpenIddictConstants.PromptValues.None, + OpenIddictConstants.PromptValues.SelectAccount + }; + /// /// Gets or sets a boolean indicating whether PKCE must be used by client applications /// when requesting an authorization code (e.g when using the code or hybrid flows). diff --git a/test/OpenIddict.Abstractions.Tests/Primitives/OpenIddictExtensionsTests.cs b/test/OpenIddict.Abstractions.Tests/Primitives/OpenIddictExtensionsTests.cs index c4a7c25d2..59527d6b0 100644 --- a/test/OpenIddict.Abstractions.Tests/Primitives/OpenIddictExtensionsTests.cs +++ b/test/OpenIddict.Abstractions.Tests/Primitives/OpenIddictExtensionsTests.cs @@ -225,7 +225,7 @@ public void HasPrompt_ThrowsAnExceptionForNullRequest() // Act and assert var exception = Assert.Throws(() => { - request.HasPrompt(Prompts.Consent); + request.HasPrompt(PromptValues.Consent); }); Assert.Equal("request", exception.ParamName); @@ -277,7 +277,7 @@ public void HasPrompt_ReturnsExpectedResult(string prompt, bool result) }; // Act and assert - Assert.Equal(result, request.HasPrompt(Prompts.Consent)); + Assert.Equal(result, request.HasPrompt(PromptValues.Consent)); } [Fact] diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Authentication.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Authentication.cs index 3c6db6975..14392037c 100644 --- a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Authentication.cs +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Authentication.cs @@ -383,14 +383,17 @@ public async Task ValidateAuthorizationRequest_MissingOpenIdScopeCausesAnErrorFo Assert.Equal(SR.FormatID8000(SR.ID2034), response.ErrorUri); } - [Theory] - [InlineData("none consent")] - [InlineData("none login")] - [InlineData("none select_account")] - public async Task ValidateAuthorizationRequest_InvalidPromptCausesAnError(string prompt) + [Fact] + public async Task ValidateAuthorizationRequest_UnsupportedPromptCausesAnError() { // Arrange - await using var server = await CreateServerAsync(options => options.EnableDegradedMode()); + 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 @@ -398,7 +401,7 @@ public async Task ValidateAuthorizationRequest_InvalidPromptCausesAnError(string { ClientId = "Fabrikam", Nonce = "n-0S6_WzA2Mj", - Prompt = prompt, + Prompt = PromptValues.SelectAccount, RedirectUri = "http://www.fabrikam.com/path", ResponseType = "code id_token token", Scope = Scopes.OpenId @@ -406,8 +409,8 @@ public async Task ValidateAuthorizationRequest_InvalidPromptCausesAnError(string // Assert Assert.Equal(Errors.InvalidRequest, response.Error); - Assert.Equal(SR.FormatID2052(Parameters.Prompt), response.ErrorDescription); - Assert.Equal(SR.FormatID8000(SR.ID2052), response.ErrorUri); + Assert.Equal(SR.FormatID2032(Parameters.Prompt), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2032), response.ErrorUri); } [Theory] diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Discovery.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Discovery.cs index 7c2b47108..85ba749c5 100644 --- a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Discovery.cs +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Discovery.cs @@ -811,6 +811,30 @@ public async Task HandleConfigurationRequest_SupportedSubjectTypesAreCorrectlyRe Assert.Equal("custom", Assert.Single(types)); } + [Fact] + public async Task HandleConfigurationRequest_SupportedPromptValuesAreCorrectlyReturned() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.Configure(options => options.PromptValues.Remove(PromptValues.Consent)); + options.Configure(options => options.PromptValues.Remove(PromptValues.Login)); + options.Configure(options => options.PromptValues.Remove(PromptValues.None)); + options.Configure(options => options.PromptValues.Remove(PromptValues.SelectAccount)); + options.Configure(options => options.PromptValues.Add("custom")); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.GetAsync("/.well-known/openid-configuration"); + var types = (string[]?) response[Metadata.PromptValuesSupported]; + + // Assert + Assert.NotNull(types); + Assert.Equal("custom", Assert.Single(types)); + } + [Theory] [InlineData(Algorithms.RsaSha256)] [InlineData(Algorithms.RsaSha384)] diff --git a/test/OpenIddict.Server.Tests/OpenIddictServerBuilderTests.cs b/test/OpenIddict.Server.Tests/OpenIddictServerBuilderTests.cs index a92261432..edb4bc2a8 100644 --- a/test/OpenIddict.Server.Tests/OpenIddictServerBuilderTests.cs +++ b/test/OpenIddict.Server.Tests/OpenIddictServerBuilderTests.cs @@ -2016,18 +2016,62 @@ public void RegisterClaims_ThrowsAnExceptionForNullClaims() [Theory] [InlineData(null)] [InlineData("")] - public void RegisterClaims_ThrowsAnExceptionForClaim(string claim) + public void RegisterClaims_ThrowsAnExceptionForNullOrEmptyClaim(string claim) { // Arrange var services = CreateServices(); var builder = CreateBuilder(services); - string[] claims = [claim]; // Act and assert - var exception = Assert.Throws(() => builder.RegisterClaims(claims)); + var exception = Assert.Throws(() => builder.RegisterClaims([claim])); Assert.Equal("claims", exception.ParamName); - Assert.Contains("Claims cannot be null or empty.", exception.Message); + Assert.Contains(SR.FormatID0457("claims"), exception.Message); + } + + [Fact] + public void RegisterPromptValues_PromptValuesAreAdded() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act + builder.RegisterPromptValues("custom_value_1", "custom_value_2"); + + var options = GetOptions(services); + + // Assert + Assert.Contains("custom_value_1", options.PromptValues); + Assert.Contains("custom_value_2", options.PromptValues); + } + + [Fact] + public void RegisterPromptValues_ThrowsAnExceptionForNullPromptValues() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => builder.RegisterPromptValues(values: null!)); + Assert.Equal("values", exception.ParamName); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void RegisterPromptValues_ThrowsAnExceptionForNullOrEmptyValue(string value) + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => builder.RegisterPromptValues([value])); + + Assert.Equal("values", exception.ParamName); + Assert.Contains(SR.FormatID0457("values"), exception.Message); } [Fact] @@ -2062,18 +2106,17 @@ public void RegisterScopes_ThrowsAnExceptionForNullScopes() [Theory] [InlineData(null)] [InlineData("")] - public void RegisterScopes_ThrowsAnExceptionForScope(string scope) + public void RegisterScopes_ThrowsAnExceptionForNullOrEmptyScope(string scope) { // Arrange var services = CreateServices(); var builder = CreateBuilder(services); - string[] scopes = [scope]; // Act and assert - var exception = Assert.Throws(() => builder.RegisterScopes(scopes)); + var exception = Assert.Throws(() => builder.RegisterScopes([scope])); Assert.Equal("scopes", exception.ParamName); - Assert.Contains("Scopes cannot be null or empty.", exception.Message); + Assert.Contains(SR.FormatID0457("scopes"), exception.Message); } [Fact]