Skip to content

Commit

Permalink
Validate prompt values specified in authorization requests and update…
Browse files Browse the repository at this point in the history
… the configuration endpoint to return "prompt_values_supported"
  • Loading branch information
kevinchalet committed Oct 4, 2024
1 parent fcc0ddd commit e1f729b
Show file tree
Hide file tree
Showing 14 changed files with 208 additions and 42 deletions.
1 change: 1 addition & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ public async Task<ActionResult> 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,
Expand Down Expand Up @@ -178,8 +178,8 @@ public async Task<ActionResult> 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<string, string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,13 @@ public async Task<IActionResult> 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,
Expand All @@ -90,7 +90,7 @@ public async Task<IActionResult> 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() :
Expand Down Expand Up @@ -173,7 +173,7 @@ public async Task<IActionResult> 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,
Expand Down Expand Up @@ -210,8 +210,8 @@ public async Task<IActionResult> 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<string, string>
Expand Down
4 changes: 3 additions & 1 deletion src/OpenIddict.Abstractions/OpenIddictConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down
14 changes: 7 additions & 7 deletions src/OpenIddict.Abstractions/OpenIddictResources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -365,12 +365,6 @@ Consider using 'options.AddSigningCredentials(SigningCredentials)' instead.</val
<data name="ID0072" xml:space="preserve">
<value>Endpoint URIs must be valid URIs.</value>
</data>
<data name="ID0073" xml:space="preserve">
<value>Claims cannot be null or empty.</value>
</data>
<data name="ID0074" xml:space="preserve">
<value>Scopes cannot be null or empty.</value>
</data>
<data name="ID0075" xml:space="preserve">
<value>The security token handler cannot be null.</value>
</data>
Expand Down Expand Up @@ -1704,6 +1698,9 @@ To apply post-logout redirection responses, create a class implementing 'IOpenId
<data name="ID0456" xml:space="preserve">
<value>The specified client authentication method/token binding methods combination is not valid.</value>
</data>
<data name="ID0457" xml:space="preserve">
<value>The '{0}' parameter cannot contain null or empty values.</value>
</data>
<data name="ID2000" xml:space="preserve">
<value>The security token is missing.</value>
</data>
Expand Down Expand Up @@ -2379,7 +2376,7 @@ The principal used to create the token contained the following claims: {Claims}.
<value>The authorization request was rejected because the '{Scope}' scope was missing.</value>
</data>
<data name="ID6040" xml:space="preserve">
<value>The authorization request was rejected because an invalid prompt parameter was specified.</value>
<value>The authorization request was rejected because an invalid prompt combination was specified.</value>
</data>
<data name="ID6041" xml:space="preserve">
<value>The authorization request was rejected because the specified code challenge method was not supported.</value>
Expand Down Expand Up @@ -2892,6 +2889,9 @@ This may indicate that the hashed entry is corrupted or malformed.</value>
<data name="ID6232" xml:space="preserve">
<value>An error was returned by ASWebAuthenticationSession while trying to start a sign-out operation.</value>
</data>
<data name="ID6233" xml:space="preserve">
<value>The authorization request was rejected because an unsupported prompt parameter was specified.</value>
</data>
<data name="ID8000" xml:space="preserve">
<value>https://documentation.openiddict.com/errors/{0}</value>
</data>
Expand Down
25 changes: 23 additions & 2 deletions src/OpenIddict.Server/OpenIddictServerBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}

/// <summary>
/// Registers the specified prompt values as supported scopes so
/// they can be returned as part of the discovery document.
/// </summary>
/// <param name="values">The supported prompt values.</param>
/// <returns>The <see cref="OpenIddictServerBuilder"/> instance.</returns>
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));
}

/// <summary>
/// Registers the specified scopes as supported scopes so
/// they can be returned as part of the discovery document.
Expand All @@ -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));
Expand Down
5 changes: 5 additions & 0 deletions src/OpenIddict.Server/OpenIddictServerEvents.Discovery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,11 @@ public OpenIddictRequest Request
/// </summary>
public HashSet<string> IntrospectionEndpointAuthenticationMethods { get; } = new(StringComparer.Ordinal);

/// <summary>
/// Gets the list of prompt values supported by the authorization server.
/// </summary>
public HashSet<string> PromptValues { get; } = new(StringComparer.Ordinal);

/// <summary>
/// Gets the list of response modes
/// supported by the authorization server.
Expand Down
29 changes: 26 additions & 3 deletions src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down
33 changes: 32 additions & 1 deletion src/OpenIddict.Server/OpenIddictServerHandlers.Discovery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ public static class Discovery
AttachScopes.Descriptor,
AttachClaims.Descriptor,
AttachSubjectTypes.Descriptor,
AttachPromptValues.Descriptor,
AttachSigningAlgorithms.Descriptor,
AttachAdditionalMetadata.Descriptor,

Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -673,6 +675,35 @@ public ValueTask HandleAsync(HandleConfigurationRequestContext context)
}
}

/// <summary>
/// Contains the logic responsible for attaching the supported prompt values to the provider discovery document.
/// </summary>
public sealed class AttachPromptValues : IOpenIddictServerHandler<HandleConfigurationRequestContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictServerHandlerDescriptor Descriptor { get; }
= OpenIddictServerHandlerDescriptor.CreateBuilder<HandleConfigurationRequestContext>()
.UseSingletonHandler<AttachPromptValues>()
.SetOrder(AttachSubjectTypes.Descriptor.Order + 1_000)
.SetType(OpenIddictServerHandlerType.BuiltIn)
.Build();

/// <inheritdoc/>
public ValueTask HandleAsync(HandleConfigurationRequestContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}

context.PromptValues.UnionWith(context.Options.PromptValues);

return default;
}
}

/// <summary>
/// Contains the logic responsible for attaching the supported signing algorithms to the provider discovery document.
/// </summary>
Expand All @@ -684,7 +715,7 @@ public sealed class AttachSigningAlgorithms : IOpenIddictServerHandler<HandleCon
public static OpenIddictServerHandlerDescriptor Descriptor { get; }
= OpenIddictServerHandlerDescriptor.CreateBuilder<HandleConfigurationRequestContext>()
.UseSingletonHandler<AttachSigningAlgorithms>()
.SetOrder(AttachSubjectTypes.Descriptor.Order + 1_000)
.SetOrder(AttachPromptValues.Descriptor.Order + 1_000)
.SetType(OpenIddictServerHandlerType.BuiltIn)
.Build();

Expand Down
13 changes: 13 additions & 0 deletions src/OpenIddict.Server/OpenIddictServerOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,19 @@ public sealed class OpenIddictServerOptions
/// </summary>
public HashSet<string> GrantTypes { get; } = new(StringComparer.Ordinal);

/// <summary>
/// Gets the OpenID Connect prompt values enabled for this application.
/// </summary>
public HashSet<string> 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
};

/// <summary>
/// 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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ public void HasPrompt_ThrowsAnExceptionForNullRequest()
// Act and assert
var exception = Assert.Throws<ArgumentNullException>(() =>
{
request.HasPrompt(Prompts.Consent);
request.HasPrompt(PromptValues.Consent);
});

Assert.Equal("request", exception.ParamName);
Expand Down Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -383,31 +383,34 @@ 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
var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest
{
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
});

// 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]
Expand Down
Loading

0 comments on commit e1f729b

Please sign in to comment.