From 78ba0a3dec86ca8d43a3da6e4a83cc66b3525496 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Chalet?= Date: Sun, 8 Jun 2025 11:30:25 +0200 Subject: [PATCH] Implement built-in audiences and resources indicators validation --- .../OpenIddictHelpers.cs | 4 +- .../OpenIddictConstants.cs | 2 + .../OpenIddictResources.resx | 51 +++ .../Primitives/OpenIddictExtensions.cs | 144 +++++- .../Primitives/OpenIddictMessage.cs | 2 +- .../Primitives/OpenIddictParameter.cs | 2 +- .../OpenIddictClientBuilder.cs | 4 +- .../OpenIddictServerBuilder.cs | 113 ++++- .../OpenIddictServerExtensions.cs | 4 + .../OpenIddictServerHandlerFilters.cs | 68 +++ ...OpenIddictServerHandlers.Authentication.cs | 416 +++++++++++++++++- .../OpenIddictServerHandlers.Exchange.cs | 328 +++++++++++++- .../OpenIddictServerOptions.cs | 35 ++ .../Primitives/OpenIddictExtensionsTests.cs | 158 ++++++- ...ctServerIntegrationTests.Authentication.cs | 246 +++++++++++ ...enIddictServerIntegrationTests.Exchange.cs | 326 ++++++++++++++ .../OpenIddictServerIntegrationTests.cs | 4 +- .../OpenIddictServerBuilderTests.cs | 203 +++++++++ 18 files changed, 2062 insertions(+), 48 deletions(-) diff --git a/shared/OpenIddict.Extensions/OpenIddictHelpers.cs b/shared/OpenIddict.Extensions/OpenIddictHelpers.cs index 12557599..c97fb081 100644 --- a/shared/OpenIddict.Extensions/OpenIddictHelpers.cs +++ b/shared/OpenIddict.Extensions/OpenIddictHelpers.cs @@ -406,7 +406,7 @@ internal static class OpenIddictHelpers 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())); + .ToDictionary(static pair => pair.Key!, static pair => new StringValues([.. pair.Select(parts => parts.Value)])); } /// @@ -430,7 +430,7 @@ internal static class OpenIddictHelpers 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())); + .ToDictionary(static pair => pair.Key!, static pair => new StringValues([.. pair.Select(parts => parts.Value)])); } /// diff --git a/src/OpenIddict.Abstractions/OpenIddictConstants.cs b/src/OpenIddict.Abstractions/OpenIddictConstants.cs index 859b758b..6ce389f5 100644 --- a/src/OpenIddict.Abstractions/OpenIddictConstants.cs +++ b/src/OpenIddict.Abstractions/OpenIddictConstants.cs @@ -423,9 +423,11 @@ public static class OpenIddictConstants public static class Prefixes { + public const string Audience = "aud:"; public const string Endpoint = "ept:"; public const string GrantType = "gt:"; public const string ResponseType = "rst:"; + public const string Resource = "rsrc:"; public const string Scope = "scp:"; } diff --git a/src/OpenIddict.Abstractions/OpenIddictResources.resx b/src/OpenIddict.Abstractions/OpenIddictResources.resx index 1df43ec6..5d37c920 100644 --- a/src/OpenIddict.Abstractions/OpenIddictResources.resx +++ b/src/OpenIddict.Abstractions/OpenIddictResources.resx @@ -1793,6 +1793,9 @@ Alternatively, any value respecting the '[region]-[subregion]-[identifier]' patt The type of the actor token cannot be resolved from the authentication context. + + The '{0}' parameter cannot contain values that are not valid absolute URIs containing no fragment component. + The security token is missing. @@ -2360,6 +2363,24 @@ Alternatively, any value respecting the '[region]-[subregion]-[identifier]' patt The specified actor token cannot be used by this client application. + + One of the specified '{0}' parameters is invalid. + + + This client application is not allowed to use the specified audience(s). + + + This client application is not allowed to use the specified resource(s). + + + The '{0}' parameter is not allowed in authorization requests. + + + The '{0}' parameter is not allowed in pushed authorization requests. + + + The '{0}' parameter is only allowed for OAuth 2.0 Token Exchange requests. + The '{0}' parameter shouldn't be null or empty at this point. @@ -3137,6 +3158,36 @@ This may indicate that the hashed entry is corrupted or malformed. The token request was rejected because the actor token was issued to a different client or for another resource server. + + The token request was rejected because invalid audiences were specified: {Audiences}. + + + The token request was rejected because invalid resources were specified: {Resources}. + + + The authorization request was rejected because invalid resources were specified: {Resources}. + + + The pushed authorization request was rejected because invalid resources were specified: {Resources}. + + + The token request was rejected because the application '{ClientId}' was not allowed to use the audience {Audience}. + + + The token request was rejected because the application '{ClientId}' was not allowed to use the resource {Resource}. + + + The authorization request was rejected because the application '{ClientId}' was not allowed to use the resource {Resource}. + + + The pushed authorization request was rejected because the application '{ClientId}' was not allowed to use the resource {Resource}. + + + The token request was rejected because the '{Parameter}' parameter wasn't a valid absolute URI: {RedirectUri}. + + + The token request was rejected because the '{Parameter}' contained a URI fragment: {RedirectUri}. + https://documentation.openiddict.com/errors/{0} diff --git a/src/OpenIddict.Abstractions/Primitives/OpenIddictExtensions.cs b/src/OpenIddict.Abstractions/Primitives/OpenIddictExtensions.cs index 206d322e..7bdab8ed 100644 --- a/src/OpenIddict.Abstractions/Primitives/OpenIddictExtensions.cs +++ b/src/OpenIddict.Abstractions/Primitives/OpenIddictExtensions.cs @@ -38,6 +38,35 @@ public static class OpenIddictExtensions return GetValues(request.AcrValues, Separators.Space); } + /// + /// Extracts the audiences from an . + /// + /// The instance. + public static ImmutableArray GetAudiences(this OpenIddictRequest request) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + if (request.Audiences is not { IsDefaultOrEmpty: false } audiences) + { + return []; + } + + HashSet set = []; + + foreach (var audience in audiences) + { + if (!string.IsNullOrEmpty(audience)) + { + set.Add(audience); + } + } + + return [.. set]; + } + /// /// Extracts the prompt values from an . /// @@ -52,6 +81,35 @@ public static class OpenIddictExtensions return GetValues(request.Prompt, Separators.Space); } + /// + /// Extracts the resources from an . + /// + /// The instance. + public static ImmutableArray GetResources(this OpenIddictRequest request) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + if (request.Resources is not { IsDefaultOrEmpty: false } resources) + { + return []; + } + + HashSet set = []; + + foreach (var resource in resources) + { + if (!string.IsNullOrEmpty(resource)) + { + set.Add(resource); + } + } + + return [.. set]; + } + /// /// Extracts the response types from an . /// @@ -94,30 +152,100 @@ public static class OpenIddictExtensions if (string.IsNullOrEmpty(value)) { - throw new ArgumentException(SR.GetResourceString(SR.ID0177), nameof(value)); + throw new ArgumentException(SR.FormatID0366(nameof(value)), nameof(value)); } return HasValue(request.AcrValues, value, Separators.Space); } + /// + /// Determines whether the requested audiences contains the specified value. + /// + /// The instance. + /// The value to look for in the parameters. + public static bool HasAudience(this OpenIddictRequest request, string audience) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + if (string.IsNullOrEmpty(audience)) + { + throw new ArgumentException(SR.FormatID0366(nameof(audience)), nameof(audience)); + } + + var audiences = request.Audiences; + if (audiences is null or []) + { + return false; + } + + for (var index = 0; index < audiences.Value.Length; index++) + { + if (audiences.Value[index] is { Length: > 0 } value && + string.Equals(value, audience, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + /// /// Determines whether the requested prompt contains the specified value. /// /// The instance. - /// The component to look for in the parameter. - public static bool HasPromptValue(this OpenIddictRequest request, string prompt) + /// The component to look for in the parameter. + public static bool HasPromptValue(this OpenIddictRequest request, string value) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + if (string.IsNullOrEmpty(value)) + { + throw new ArgumentException(SR.FormatID0366(nameof(value)), nameof(value)); + } + + return HasValue(request.Prompt, value, Separators.Space); + } + + /// + /// Determines whether the requested resources contains the specified value. + /// + /// The instance. + /// The value to look for in the parameters. + public static bool HasResource(this OpenIddictRequest request, string resource) { if (request is null) { throw new ArgumentNullException(nameof(request)); } - if (string.IsNullOrEmpty(prompt)) + if (string.IsNullOrEmpty(resource)) + { + throw new ArgumentException(SR.FormatID0366(nameof(resource)), nameof(resource)); + } + + var resources = request.Resources; + if (resources is null or []) + { + return false; + } + + for (var index = 0; index < resources.Value.Length; index++) { - throw new ArgumentException(SR.GetResourceString(SR.ID0178), nameof(prompt)); + if (resources.Value[index] is { Length: > 0 } value && + string.Equals(value, resource, StringComparison.Ordinal)) + { + return true; + } } - return HasValue(request.Prompt, prompt, Separators.Space); + return false; } /// @@ -134,7 +262,7 @@ public static class OpenIddictExtensions if (string.IsNullOrEmpty(type)) { - throw new ArgumentException(SR.GetResourceString(SR.ID0179), nameof(type)); + throw new ArgumentException(SR.FormatID0366(nameof(type)), nameof(type)); } return HasValue(request.ResponseType, type, Separators.Space); @@ -154,7 +282,7 @@ public static class OpenIddictExtensions if (string.IsNullOrEmpty(scope)) { - throw new ArgumentException(SR.GetResourceString(SR.ID0180), nameof(scope)); + throw new ArgumentException(SR.FormatID0366(nameof(scope)), nameof(scope)); } return HasValue(request.Scope, scope, Separators.Space); diff --git a/src/OpenIddict.Abstractions/Primitives/OpenIddictMessage.cs b/src/OpenIddict.Abstractions/Primitives/OpenIddictMessage.cs index 4dff1439..aa3cf153 100644 --- a/src/OpenIddict.Abstractions/Primitives/OpenIddictMessage.cs +++ b/src/OpenIddict.Abstractions/Primitives/OpenIddictMessage.cs @@ -182,7 +182,7 @@ public class OpenIddictMessage // parameters with the same name to represent a multi-valued parameter. AddParameter(parameter.Key, parameter.Value switch { - null or [] => default, + null or [] => default, [string value] => new OpenIddictParameter(value), [..] values => new OpenIddictParameter(values) }); diff --git a/src/OpenIddict.Abstractions/Primitives/OpenIddictParameter.cs b/src/OpenIddict.Abstractions/Primitives/OpenIddictParameter.cs index 3eaabbe0..01e2a9c1 100644 --- a/src/OpenIddict.Abstractions/Primitives/OpenIddictParameter.cs +++ b/src/OpenIddict.Abstractions/Primitives/OpenIddictParameter.cs @@ -1165,7 +1165,7 @@ public readonly struct OpenIddictParameter : IEquatable null or JsonElement { ValueKind: JsonValueKind.Null or JsonValueKind.Undefined } => null, // When the parameter is an array of strings, return a StringValues instance wrapping the cloned array. - string?[] value => new StringValues(value.ToArray().ToArray()), + string?[] value => new StringValues([.. value]), // When the parameter is a string value, return a StringValues instance with a single entry. string value => new StringValues(value), diff --git a/src/OpenIddict.Client/OpenIddictClientBuilder.cs b/src/OpenIddict.Client/OpenIddictClientBuilder.cs index 746cc1bb..7c14f302 100644 --- a/src/OpenIddict.Client/OpenIddictClientBuilder.cs +++ b/src/OpenIddict.Client/OpenIddictClientBuilder.cs @@ -1144,7 +1144,7 @@ public sealed class OpenIddictClientBuilder throw new ArgumentNullException(nameof(uris)); } - return SetPostLogoutRedirectionEndpointUris(uris.Select(uri => new Uri(uri, UriKind.RelativeOrAbsolute)).ToArray()); + return SetPostLogoutRedirectionEndpointUris([.. uris.Select(uri => new Uri(uri, UriKind.RelativeOrAbsolute))]); } /// @@ -1197,7 +1197,7 @@ public sealed class OpenIddictClientBuilder throw new ArgumentNullException(nameof(uris)); } - return SetRedirectionEndpointUris(uris.Select(uri => new Uri(uri, UriKind.RelativeOrAbsolute)).ToArray()); + return SetRedirectionEndpointUris([.. uris.Select(uri => new Uri(uri, UriKind.RelativeOrAbsolute))]); } /// diff --git a/src/OpenIddict.Server/OpenIddictServerBuilder.cs b/src/OpenIddict.Server/OpenIddictServerBuilder.cs index d21b22ab..0b8cca05 100644 --- a/src/OpenIddict.Server/OpenIddictServerBuilder.cs +++ b/src/OpenIddict.Server/OpenIddictServerBuilder.cs @@ -1113,7 +1113,7 @@ public sealed class OpenIddictServerBuilder throw new ArgumentNullException(nameof(uris)); } - return SetAuthorizationEndpointUris(uris.Select(uri => new Uri(uri, UriKind.RelativeOrAbsolute)).ToArray()); + return SetAuthorizationEndpointUris([.. uris.Select(uri => new Uri(uri, UriKind.RelativeOrAbsolute))]); } /// @@ -1162,7 +1162,7 @@ public sealed class OpenIddictServerBuilder throw new ArgumentNullException(nameof(uris)); } - return SetConfigurationEndpointUris(uris.Select(uri => new Uri(uri, UriKind.RelativeOrAbsolute)).ToArray()); + return SetConfigurationEndpointUris([.. uris.Select(uri => new Uri(uri, UriKind.RelativeOrAbsolute))]); } /// @@ -1211,7 +1211,7 @@ public sealed class OpenIddictServerBuilder throw new ArgumentNullException(nameof(uris)); } - return SetDeviceAuthorizationEndpointUris(uris.Select(uri => new Uri(uri, UriKind.RelativeOrAbsolute)).ToArray()); + return SetDeviceAuthorizationEndpointUris([.. uris.Select(uri => new Uri(uri, UriKind.RelativeOrAbsolute))]); } /// @@ -1260,7 +1260,7 @@ public sealed class OpenIddictServerBuilder throw new ArgumentNullException(nameof(uris)); } - return SetEndSessionEndpointUris(uris.Select(uri => new Uri(uri, UriKind.RelativeOrAbsolute)).ToArray()); + return SetEndSessionEndpointUris([.. uris.Select(uri => new Uri(uri, UriKind.RelativeOrAbsolute))]); } /// @@ -1309,7 +1309,7 @@ public sealed class OpenIddictServerBuilder throw new ArgumentNullException(nameof(uris)); } - return SetIntrospectionEndpointUris(uris.Select(uri => new Uri(uri, UriKind.RelativeOrAbsolute)).ToArray()); + return SetIntrospectionEndpointUris([.. uris.Select(uri => new Uri(uri, UriKind.RelativeOrAbsolute))]); } /// @@ -1358,7 +1358,7 @@ public sealed class OpenIddictServerBuilder throw new ArgumentNullException(nameof(uris)); } - return SetJsonWebKeySetEndpointUris(uris.Select(uri => new Uri(uri, UriKind.RelativeOrAbsolute)).ToArray()); + return SetJsonWebKeySetEndpointUris([.. uris.Select(uri => new Uri(uri, UriKind.RelativeOrAbsolute))]); } /// @@ -1407,7 +1407,7 @@ public sealed class OpenIddictServerBuilder throw new ArgumentNullException(nameof(uris)); } - return SetPushedAuthorizationEndpointUris(uris.Select(uri => new Uri(uri, UriKind.RelativeOrAbsolute)).ToArray()); + return SetPushedAuthorizationEndpointUris([.. uris.Select(uri => new Uri(uri, UriKind.RelativeOrAbsolute))]); } /// @@ -1456,7 +1456,7 @@ public sealed class OpenIddictServerBuilder throw new ArgumentNullException(nameof(uris)); } - return SetRevocationEndpointUris(uris.Select(uri => new Uri(uri, UriKind.RelativeOrAbsolute)).ToArray()); + return SetRevocationEndpointUris([.. uris.Select(uri => new Uri(uri, UriKind.RelativeOrAbsolute))]); } /// @@ -1505,7 +1505,7 @@ public sealed class OpenIddictServerBuilder throw new ArgumentNullException(nameof(uris)); } - return SetTokenEndpointUris(uris.Select(uri => new Uri(uri, UriKind.RelativeOrAbsolute)).ToArray()); + return SetTokenEndpointUris([.. uris.Select(uri => new Uri(uri, UriKind.RelativeOrAbsolute))]); } /// @@ -1554,7 +1554,7 @@ public sealed class OpenIddictServerBuilder throw new ArgumentNullException(nameof(uris)); } - return SetUserInfoEndpointUris(uris.Select(uri => new Uri(uri, UriKind.RelativeOrAbsolute)).ToArray()); + return SetUserInfoEndpointUris([.. uris.Select(uri => new Uri(uri, UriKind.RelativeOrAbsolute))]); } /// @@ -1603,7 +1603,7 @@ public sealed class OpenIddictServerBuilder throw new ArgumentNullException(nameof(uris)); } - return SetEndUserVerificationEndpointUris(uris.Select(uri => new Uri(uri, UriKind.RelativeOrAbsolute)).ToArray()); + return SetEndUserVerificationEndpointUris([.. uris.Select(uri => new Uri(uri, UriKind.RelativeOrAbsolute))]); } /// @@ -1646,6 +1646,14 @@ public sealed class OpenIddictServerBuilder public OpenIddictServerBuilder DisableAccessTokenEncryption() => Configure(options => options.DisableAccessTokenEncryption = true); + /// + /// Allows processing authorization and token requests that specify audiences that + /// have not been registered using . + /// + /// The instance. + public OpenIddictServerBuilder DisableAudienceValidation() + => Configure(options => options.DisableAudienceValidation = true); + /// /// Disables authorization storage so that ad-hoc authorizations are /// not created when an authorization code or refresh token is issued @@ -1656,6 +1664,14 @@ public sealed class OpenIddictServerBuilder public OpenIddictServerBuilder DisableAuthorizationStorage() => Configure(options => options.DisableAuthorizationStorage = true); + /// + /// Allows processing authorization and token requests that specify resources that + /// have not been registered using . + /// + /// The instance. + public OpenIddictServerBuilder DisableResourceValidation() + => Configure(options => options.DisableResourceValidation = true); + /// /// Configures OpenIddict to disable rolling refresh tokens so /// that refresh tokens used in a token request are not marked @@ -1707,6 +1723,13 @@ public sealed class OpenIddictServerBuilder public OpenIddictServerBuilder EnableDegradedMode() => Configure(options => options.EnableDegradedMode = true); + /// + /// Disables audience permissions enforcement. Calling this method is NOT recommended. + /// + /// The instance. + public OpenIddictServerBuilder IgnoreAudiencePermissions() + => Configure(options => options.IgnoreAudiencePermissions = true); + /// /// Disables endpoint permissions enforcement. Calling this method is NOT recommended. /// @@ -1721,6 +1744,13 @@ public sealed class OpenIddictServerBuilder public OpenIddictServerBuilder IgnoreGrantTypePermissions() => Configure(options => options.IgnoreGrantTypePermissions = true); + /// + /// Disables resource permissions enforcement. Calling this method is NOT recommended. + /// + /// The instance. + public OpenIddictServerBuilder IgnoreResourcePermissions() + => Configure(options => options.IgnoreResourcePermissions = true); + /// /// Disables response type permissions enforcement. Calling this method is NOT recommended. /// @@ -1735,6 +1765,27 @@ public sealed class OpenIddictServerBuilder public OpenIddictServerBuilder IgnoreScopePermissions() => Configure(options => options.IgnoreScopePermissions = true); + /// + /// Registers the specified audiences as supported audiences + /// (exclusively used with the OAuth 2.0 Token Exchange flow). + /// + /// The supported audiences. + /// The instance. + public OpenIddictServerBuilder RegisterAudiences(params string[] audiences) + { + if (audiences is null) + { + throw new ArgumentNullException(nameof(audiences)); + } + + if (Array.Exists(audiences, string.IsNullOrEmpty)) + { + throw new ArgumentException(SR.FormatID0457(nameof(audiences)), nameof(audiences)); + } + + return Configure(options => options.Audiences.UnionWith(audiences)); + } + /// /// Registers the specified claims as supported claims so /// they can be returned as part of the discovery document. @@ -1777,6 +1828,46 @@ public sealed class OpenIddictServerBuilder return Configure(options => options.PromptValues.UnionWith(values)); } + /// + /// Registers the specified resources as supported resources (typically used + /// with the OAuth 2.0 Token Exchange flow and with authorization or pushed + /// authorization requests that include one or more resource indicators). + /// + /// The supported resources. + /// The instance. + public OpenIddictServerBuilder RegisterResources(params string[] resources) + { + if (resources is null) + { + throw new ArgumentNullException(nameof(resources)); + } + + return RegisterResources([.. resources.Select(resource => new Uri(resource, UriKind.Absolute))]); + } + + /// + /// Registers the specified resources as supported resources (typically used + /// with the OAuth 2.0 Token Exchange flow and with authorization or pushed + /// authorization requests that include one or more resource indicators). + /// + /// The supported resources. + /// The instance. + public OpenIddictServerBuilder RegisterResources(params Uri[] resources) + { + if (resources is null) + { + throw new ArgumentNullException(nameof(resources)); + } + + if (Array.Exists(resources, static resource => OpenIddictHelpers.IsImplicitFileUri(resource) || + !string.IsNullOrEmpty(resource.Fragment))) + { + throw new ArgumentException(SR.FormatID0495(nameof(resources)), nameof(resources)); + } + + return Configure(options => options.Resources.UnionWith(resources)); + } + /// /// Registers the specified scopes as supported scopes so /// they can be returned as part of the discovery document. diff --git a/src/OpenIddict.Server/OpenIddictServerExtensions.cs b/src/OpenIddict.Server/OpenIddictServerExtensions.cs index 7cf6897d..0a97e691 100644 --- a/src/OpenIddict.Server/OpenIddictServerExtensions.cs +++ b/src/OpenIddict.Server/OpenIddictServerExtensions.cs @@ -43,6 +43,8 @@ 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(); @@ -77,6 +79,8 @@ 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(); diff --git a/src/OpenIddict.Server/OpenIddictServerHandlerFilters.cs b/src/OpenIddict.Server/OpenIddictServerHandlerFilters.cs index e6828a35..22aa607f 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlerFilters.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlerFilters.cs @@ -62,6 +62,40 @@ public static class OpenIddictServerHandlerFilters } } + /// + /// Represents a filter that excludes the associated handlers if audience permissions were disabled. + /// + public sealed class RequireAudiencePermissionsEnabled : IOpenIddictServerHandlerFilter + { + /// + public ValueTask IsActiveAsync(BaseContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + return new(!context.Options.IgnoreAudiencePermissions); + } + } + + /// + /// Represents a filter that excludes the associated handlers if audience validation was not enabled. + /// + public sealed class RequireAudienceValidationEnabled : IOpenIddictServerHandlerFilter + { + /// + public ValueTask IsActiveAsync(BaseContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + return new(!context.Options.DisableAudienceValidation); + } + } + /// /// Represents a filter that excludes the associated handlers if no authorization code is generated. /// @@ -640,6 +674,40 @@ public static class OpenIddictServerHandlerFilters } } + /// + /// Represents a filter that excludes the associated handlers if resource permissions were disabled. + /// + public sealed class RequireResourcePermissionsEnabled : IOpenIddictServerHandlerFilter + { + /// + public ValueTask IsActiveAsync(BaseContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + return new(!context.Options.IgnoreResourcePermissions); + } + } + + /// + /// Represents a filter that excludes the associated handlers if resource validation was not enabled. + /// + public sealed class RequireResourceValidationEnabled : IOpenIddictServerHandlerFilter + { + /// + public ValueTask IsActiveAsync(BaseContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + return new(!context.Options.DisableResourceValidation); + } + } + /// /// Represents a filter that excludes the associated handlers if response type permissions were disabled. /// diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs index 94ff9142..3e4aed39 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs @@ -44,16 +44,20 @@ public static partial class OpenIddictServerHandlers ValidateResponseTypeParameter.Descriptor, ValidateResponseModeParameter.Descriptor, ValidateScopeParameter.Descriptor, + ValidateAudienceParameter.Descriptor, + ValidateResourceParameter.Descriptor, ValidateNonceParameter.Descriptor, ValidatePromptParameter.Descriptor, ValidateProofKeyForCodeExchangeParameters.Descriptor, ValidateResponseType.Descriptor, ValidateClientRedirectUri.Descriptor, ValidateScopes.Descriptor, + ValidateResources.Descriptor, ValidateEndpointPermissions.Descriptor, ValidateGrantTypePermissions.Descriptor, ValidateResponseTypePermissions.Descriptor, ValidateScopePermissions.Descriptor, + ValidateResourcePermissions.Descriptor, ValidatePushedAuthorizationRequestsRequirement.Descriptor, ValidateProofKeyForCodeExchangeRequirement.Descriptor, ValidateAuthorizedParty.Descriptor, @@ -92,6 +96,8 @@ public static partial class OpenIddictServerHandlers ValidatePushedResponseTypeParameter.Descriptor, ValidatePushedResponseModeParameter.Descriptor, ValidatePushedScopeParameter.Descriptor, + ValidatePushedAudienceParameter.Descriptor, + ValidatePushedResourceParameter.Descriptor, ValidatePushedNonceParameter.Descriptor, ValidatePushedPromptParameter.Descriptor, ValidatePushedProofKeyForCodeExchangeParameters.Descriptor, @@ -99,10 +105,12 @@ public static partial class OpenIddictServerHandlers ValidatePushedResponseType.Descriptor, ValidatePushedClientRedirectUri.Descriptor, ValidatePushedScopes.Descriptor, + ValidatePushedResources.Descriptor, ValidatePushedEndpointPermissions.Descriptor, ValidatePushedGrantTypePermissions.Descriptor, ValidatePushedResponseTypePermissions.Descriptor, ValidatePushedScopePermissions.Descriptor, + ValidatePushedResourcePermissions.Descriptor, ValidatePushedProofKeyForCodeExchangeRequirement.Descriptor, ValidatePushedAuthorizedParty.Descriptor, @@ -1055,6 +1063,103 @@ public static partial class OpenIddictServerHandlers } } + /// + /// Contains the logic responsible for rejecting authorization requests that specify an audience parameter. + /// + public sealed class ValidateAudienceParameter : IOpenIddictServerHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ValidateScopeParameter.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ValidateAuthorizationRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // Prevent audiences parameters from being attached to authorization requests, as the + // standard "audience" parameter can only be used in OAuth 2.0 Token Exchange requests. + if (context.Request.Audiences is not (null or [])) + { + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2193(Parameters.Audience), + uri: SR.FormatID8000(SR.ID2193)); + + return default; + } + + return default; + } + } + + /// + /// Contains the logic responsible for rejecting authorization requests that don't specify a valid resource parameter. + /// + public sealed class ValidateResourceParameter : IOpenIddictServerHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ValidateAudienceParameter.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ValidateAuthorizationRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + foreach (var resource in context.Request.GetResources()) + { + // Note: resource indicators MUST be valid URIs. + // + // For more information, see https://datatracker.ietf.org/doc/html/rfc8707#name-resource-parameter. + if (!Uri.TryCreate(resource, UriKind.Absolute, out Uri? uri) || OpenIddictHelpers.IsImplicitFileUri(uri)) + { + context.Logger.LogInformation(6034, SR.GetResourceString(SR.ID6034), Parameters.Resource, resource); + + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2030(Parameters.Resource), + uri: SR.FormatID8000(SR.ID2030)); + + return default; + } + + // Note: resource indicators MUST NOT contain a fragment. + if (!string.IsNullOrEmpty(uri.Fragment)) + { + context.Logger.LogInformation(6035, SR.GetResourceString(SR.ID6035), Parameters.Resource, resource); + + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2031(Parameters.Resource), + uri: SR.FormatID8000(SR.ID2031)); + + return default; + } + } + + return default; + } + } + /// /// Contains the logic responsible for rejecting authorization requests that don't specify a nonce. /// @@ -1066,7 +1171,7 @@ public static partial class OpenIddictServerHandlers public static OpenIddictServerHandlerDescriptor Descriptor { get; } = OpenIddictServerHandlerDescriptor.CreateBuilder() .UseSingletonHandler() - .SetOrder(ValidateScopeParameter.Descriptor.Order + 1_000) + .SetOrder(ValidateResourceParameter.Descriptor.Order + 1_000) .SetType(OpenIddictServerHandlerType.BuiltIn) .Build(); @@ -1524,6 +1629,50 @@ public static partial class OpenIddictServerHandlers } } + /// + /// Contains the logic responsible for rejecting authorization requests that use unregistered resources. + /// + public sealed class ValidateResources : IOpenIddictServerHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(ValidateScopes.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ValidateAuthorizationRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // If at least one resource was not recognized, return an error. + var resources = context.Request.GetResources().ToHashSet(StringComparer.Ordinal); + resources.ExceptWith(context.Options.Resources.Select(static resource => resource.AbsoluteUri)); + + if (resources.Count is not 0) + { + context.Logger.LogInformation(6275, SR.GetResourceString(SR.ID6274), resources); + + context.Reject( + error: Errors.InvalidTarget, + description: SR.FormatID2190(Parameters.Resource), + uri: SR.FormatID8000(SR.ID2190)); + + return default; + } + + return default; + } + } + /// /// Contains the logic responsible for rejecting authorization requests made by unauthorized applications. /// Note: this handler is not used when the degraded mode is enabled or when endpoint permissions are disabled. @@ -1545,7 +1694,7 @@ public static partial class OpenIddictServerHandlers .AddFilter() .AddFilter() .UseScopedHandler() - .SetOrder(ValidateScopes.Descriptor.Order + 1_000) + .SetOrder(ValidateResources.Descriptor.Order + 1_000) .SetType(OpenIddictServerHandlerType.BuiltIn) .Build(); @@ -1816,6 +1965,63 @@ public static partial class OpenIddictServerHandlers } } + /// + /// Contains the logic responsible for rejecting authorization requests made by + /// applications that haven't been granted the appropriate audience permissions. + /// Note: this handler is not used when the degraded mode is enabled or when audience permissions are disabled. + /// + public sealed class ValidateResourcePermissions : IOpenIddictServerHandler + { + private readonly IOpenIddictApplicationManager _applicationManager; + + public ValidateResourcePermissions() => throw new InvalidOperationException(SR.GetResourceString(SR.ID0016)); + + public ValidateResourcePermissions(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(ValidateScopePermissions.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)); + + var application = await _applicationManager.FindByClientIdAsync(context.ClientId) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0032)); + + foreach (var resource in context.Request.GetResources()) + { + // Reject the request if the application is not allowed to use the iterated resource. + if (!await _applicationManager.HasPermissionAsync(application, Permissions.Prefixes.Resource + resource)) + { + context.Logger.LogInformation(6281, SR.GetResourceString(SR.ID6278), context.ClientId, resource); + + context.Reject( + error: Errors.InvalidRequest, + description: SR.GetResourceString(SR.ID2192), + uri: SR.FormatID8000(SR.ID2192)); + + return; + } + } + } + } + /// /// Contains the logic responsible for rejecting authorization requests made by /// applications for which pushed authorization requests (PAR) are enforced. @@ -1837,7 +2043,7 @@ public static partial class OpenIddictServerHandlers = OpenIddictServerHandlerDescriptor.CreateBuilder() .AddFilter() .UseScopedHandler() - .SetOrder(ValidateScopePermissions.Descriptor.Order + 1_000) + .SetOrder(ValidateResourcePermissions.Descriptor.Order + 1_000) .SetType(OpenIddictServerHandlerType.BuiltIn) .Build(); @@ -2951,6 +3157,103 @@ public static partial class OpenIddictServerHandlers } } + /// + /// Contains the logic responsible for rejecting pushed authorization requests that specify an audience parameter. + /// + public sealed class ValidatePushedAudienceParameter : 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)); + } + + // Prevent audiences parameters from being attached to pushed authorization requests, as the + // standard "audience" parameter can only be used in OAuth 2.0 Token Exchange requests. + if (context.Request.Audiences is not (null or [])) + { + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2194(Parameters.Audience), + uri: SR.FormatID8000(SR.ID2194)); + + return default; + } + + return default; + } + } + + /// + /// Contains the logic responsible for rejecting pushed authorization requests that don't specify a valid resource parameter. + /// + public sealed class ValidatePushedResourceParameter : IOpenIddictServerHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ValidatePushedAudienceParameter.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ValidatePushedAuthorizationRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + foreach (var resource in context.Request.GetResources()) + { + // Note: resource indicators MUST be valid URIs. + // + // For more information, see https://datatracker.ietf.org/doc/html/rfc8707#name-resource-parameter. + if (!Uri.TryCreate(resource, UriKind.Absolute, out Uri? uri) || OpenIddictHelpers.IsImplicitFileUri(uri)) + { + context.Logger.LogInformation(6241, SR.GetResourceString(SR.ID6241), Parameters.Resource, resource); + + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2030(Parameters.Resource), + uri: SR.FormatID8000(SR.ID2030)); + + return default; + } + + // Note: resource indicators MUST NOT contain a fragment. + if (!string.IsNullOrEmpty(uri.Fragment)) + { + context.Logger.LogInformation(6242, SR.GetResourceString(SR.ID6242), Parameters.Resource, resource); + + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2031(Parameters.Resource), + uri: SR.FormatID8000(SR.ID2031)); + + return default; + } + } + + return default; + } + } + /// /// Contains the logic responsible for rejecting pushed authorization requests that don't specify a nonce. /// @@ -2962,7 +3265,7 @@ public static partial class OpenIddictServerHandlers public static OpenIddictServerHandlerDescriptor Descriptor { get; } = OpenIddictServerHandlerDescriptor.CreateBuilder() .UseSingletonHandler() - .SetOrder(ValidatePushedScopeParameter.Descriptor.Order + 1_000) + .SetOrder(ValidatePushedResourceParameter.Descriptor.Order + 1_000) .SetType(OpenIddictServerHandlerType.BuiltIn) .Build(); @@ -3481,6 +3784,50 @@ public static partial class OpenIddictServerHandlers } } + /// + /// Contains the logic responsible for rejecting pushed authorization requests that use unregistered resources. + /// + public sealed class ValidatePushedResources : IOpenIddictServerHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(ValidatePushedScopes.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ValidatePushedAuthorizationRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // If at least one resource was not recognized, return an error. + var resources = context.Request.GetResources().ToHashSet(StringComparer.Ordinal); + resources.ExceptWith(context.Options.Resources.Select(static resource => resource.AbsoluteUri)); + + if (resources.Count is not 0) + { + context.Logger.LogInformation(6275, SR.GetResourceString(SR.ID6275), resources); + + context.Reject( + error: Errors.InvalidTarget, + description: SR.FormatID2190(Parameters.Resource), + uri: SR.FormatID8000(SR.ID2190)); + + return default; + } + + return default; + } + } + /// /// 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. @@ -3502,7 +3849,7 @@ public static partial class OpenIddictServerHandlers .AddFilter() .AddFilter() .UseScopedHandler() - .SetOrder(ValidatePushedScopes.Descriptor.Order + 1_000) + .SetOrder(ValidatePushedResources.Descriptor.Order + 1_000) .SetType(OpenIddictServerHandlerType.BuiltIn) .Build(); @@ -3773,6 +4120,63 @@ public static partial class OpenIddictServerHandlers } } + /// + /// Contains the logic responsible for rejecting pushed authorization requests made by + /// applications that haven't been granted the appropriate audience permissions. + /// Note: this handler is not used when the degraded mode is enabled or when audience permissions are disabled. + /// + public sealed class ValidatePushedResourcePermissions : IOpenIddictServerHandler + { + private readonly IOpenIddictApplicationManager _applicationManager; + + public ValidatePushedResourcePermissions() => throw new InvalidOperationException(SR.GetResourceString(SR.ID0016)); + + public ValidatePushedResourcePermissions(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(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)); + + var application = await _applicationManager.FindByClientIdAsync(context.ClientId) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0032)); + + foreach (var resource in context.Request.GetResources()) + { + // Reject the request if the application is not allowed to use the iterated resource. + if (!await _applicationManager.HasPermissionAsync(application, Permissions.Prefixes.Resource + resource)) + { + context.Logger.LogInformation(6283, SR.GetResourceString(SR.ID6279), context.ClientId, resource); + + context.Reject( + error: Errors.InvalidRequest, + description: SR.GetResourceString(SR.ID2192), + uri: SR.FormatID8000(SR.ID2192)); + + return; + } + } + } + } + /// /// Contains the logic responsible for rejecting pushed authorization requests made by /// applications for which proof key for code exchange (PKCE) was enforced. @@ -3794,7 +4198,7 @@ public static partial class OpenIddictServerHandlers = OpenIddictServerHandlerDescriptor.CreateBuilder() .AddFilter() .UseScopedHandler() - .SetOrder(ValidatePushedScopePermissions.Descriptor.Order + 1_000) + .SetOrder(ValidatePushedResourcePermissions.Descriptor.Order + 1_000) .SetType(OpenIddictServerHandlerType.BuiltIn) .Build(); diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Exchange.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Exchange.cs index 7fef7d96..d6842445 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Exchange.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Exchange.cs @@ -47,11 +47,17 @@ public static partial class OpenIddictServerHandlers ValidateResourceOwnerCredentialsParameters.Descriptor, ValidateProofKeyForCodeExchangeParameters.Descriptor, ValidateScopeParameter.Descriptor, + ValidateAudienceParameter.Descriptor, + ValidateResourceParameter.Descriptor, ValidateScopes.Descriptor, + ValidateAudiences.Descriptor, + ValidateResources.Descriptor, ValidateAuthentication.Descriptor, ValidateEndpointPermissions.Descriptor, ValidateGrantTypePermissions.Descriptor, ValidateScopePermissions.Descriptor, + ValidateAudiencePermissions.Descriptor, + ValidateResourcePermissions.Descriptor, ValidateProofKeyForCodeExchangeRequirement.Descriptor, ValidateAuthorizedParty.Descriptor, ValidateRedirectUri.Descriptor, @@ -946,7 +952,111 @@ public static partial class OpenIddictServerHandlers } /// - /// Contains the logic responsible for rejecting authorization requests that use unregistered scopes. + /// Contains the logic responsible for rejecting token requests that specify an audience parameter in an invalid context. + /// + public sealed class ValidateAudienceParameter : IOpenIddictServerHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ValidateScopeParameter.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ValidateTokenRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (context.Request.Audiences is null or []) + { + return default; + } + + // Prevent audiences parameters from being attached to token requests that don't use the + // OAuth 2.0 Token Exchange grant type, unless the specified grant type is a custom value. + if (context.Request.IsAuthorizationCodeGrantType() || context.Request.IsClientCredentialsGrantType() || + context.Request.IsDeviceCodeGrantType() || context.Request.IsImplicitFlow() || + context.Request.IsPasswordGrantType() || context.Request.IsRefreshTokenGrantType()) + { + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2195(Parameters.Audience), + uri: SR.FormatID8000(SR.ID2195)); + + return default; + } + + return default; + } + } + + /// + /// Contains the logic responsible for rejecting token requests that don't specify a valid resource parameter. + /// + public sealed class ValidateResourceParameter : IOpenIddictServerHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ValidateAudienceParameter.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ValidateTokenRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + foreach (var resource in context.Request.GetResources()) + { + // Note: resource indicators MUST be valid URIs. + // + // For more information, see https://datatracker.ietf.org/doc/html/rfc8707#name-resource-parameter. + if (!Uri.TryCreate(resource, UriKind.Absolute, out Uri? uri) || OpenIddictHelpers.IsImplicitFileUri(uri)) + { + context.Logger.LogInformation(6284, SR.GetResourceString(SR.ID6280), Parameters.Resource, resource); + + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2030(Parameters.Resource), + uri: SR.FormatID8000(SR.ID2030)); + + return default; + } + + // Note: resource indicators MUST NOT contain a fragment. + if (!string.IsNullOrEmpty(uri.Fragment)) + { + context.Logger.LogInformation(6285, SR.GetResourceString(SR.ID6281), Parameters.Resource, resource); + + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2031(Parameters.Resource), + uri: SR.FormatID8000(SR.ID2031)); + + return default; + } + } + + return default; + } + } + + /// + /// Contains the logic responsible for rejecting token 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 ValidateScopes : IOpenIddictServerHandler @@ -973,7 +1083,7 @@ public static partial class OpenIddictServerHandlers new ValidateScopes(provider.GetService() ?? throw new InvalidOperationException(SR.GetResourceString(SR.ID0016))); }) - .SetOrder(ValidateScopeParameter.Descriptor.Order + 1_000) + .SetOrder(ValidateResourceParameter.Descriptor.Order + 1_000) .SetType(OpenIddictServerHandlerType.BuiltIn) .Build(); @@ -1024,6 +1134,94 @@ public static partial class OpenIddictServerHandlers } } + /// + /// Contains the logic responsible for rejecting token requests that use unregistered audiences. + /// + public sealed class ValidateAudiences : IOpenIddictServerHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(ValidateScopes.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ValidateTokenRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // If at least one audience was not recognized, return an error. + var audiences = context.Request.GetAudiences().ToHashSet(StringComparer.Ordinal); + audiences.ExceptWith(context.Options.Audiences); + + if (audiences.Count is not 0) + { + context.Logger.LogInformation(6272, SR.GetResourceString(SR.ID6272), audiences); + + context.Reject( + error: Errors.InvalidTarget, + description: SR.FormatID2190(Parameters.Audience), + uri: SR.FormatID8000(SR.ID2190)); + + return default; + } + + return default; + } + } + + /// + /// Contains the logic responsible for rejecting token requests that use unregistered resources. + /// + public sealed class ValidateResources : IOpenIddictServerHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(ValidateAudiences.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ValidateTokenRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // If at least one resource was not recognized, return an error. + var resources = context.Request.GetResources().ToHashSet(StringComparer.Ordinal); + resources.ExceptWith(context.Options.Resources.Select(static resource => resource.AbsoluteUri)); + + if (resources.Count is not 0) + { + context.Logger.LogInformation(6273, SR.GetResourceString(SR.ID6273), resources); + + context.Reject( + error: Errors.InvalidTarget, + description: SR.FormatID2190(Parameters.Resource), + uri: SR.FormatID8000(SR.ID2190)); + + return default; + } + + return default; + } + } + /// /// Contains the logic responsible for applying the authentication logic to token requests. /// @@ -1040,7 +1238,7 @@ public static partial class OpenIddictServerHandlers public static OpenIddictServerHandlerDescriptor Descriptor { get; } = OpenIddictServerHandlerDescriptor.CreateBuilder() .UseScopedHandler() - .SetOrder(ValidateScopes.Descriptor.Order + 1_000) + .SetOrder(ValidateResources.Descriptor.Order + 1_000) .SetType(OpenIddictServerHandlerType.BuiltIn) .Build(); @@ -1215,9 +1413,9 @@ public static partial class OpenIddictServerHandlers } /// - /// Contains the logic responsible for rejecting token requests made by applications - /// that haven't been granted the appropriate grant type permission. - /// Note: this handler is not used when the degraded mode is enabled. + /// Contains the logic responsible for rejecting token requests made by + /// applications that haven't been granted the appropriate scope permissions. + /// Note: this handler is not used when the degraded mode is enabled or when scope permissions are disabled. /// public sealed class ValidateScopePermissions : IOpenIddictServerHandler { @@ -1279,6 +1477,122 @@ public static partial class OpenIddictServerHandlers } } + /// + /// Contains the logic responsible for rejecting token requests made by + /// applications that haven't been granted the appropriate audience permissions. + /// Note: this handler is not used when the degraded mode is enabled or when audience permissions are disabled. + /// + public sealed class ValidateAudiencePermissions : IOpenIddictServerHandler + { + private readonly IOpenIddictApplicationManager _applicationManager; + + public ValidateAudiencePermissions() => throw new InvalidOperationException(SR.GetResourceString(SR.ID0016)); + + public ValidateAudiencePermissions(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() + .AddFilter() + .UseScopedHandler() + .SetOrder(ValidateGrantTypePermissions.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public async ValueTask HandleAsync(ValidateTokenRequestContext 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 audience in context.Request.GetAudiences()) + { + // Reject the request if the application is not allowed to use the iterated audience. + if (!await _applicationManager.HasPermissionAsync(application, Permissions.Prefixes.Audience + audience)) + { + context.Logger.LogInformation(6278, SR.GetResourceString(SR.ID6276), context.ClientId, audience); + + context.Reject( + error: Errors.InvalidRequest, + description: SR.GetResourceString(SR.ID2191), + uri: SR.FormatID8000(SR.ID2191)); + + return; + } + } + } + } + + /// + /// Contains the logic responsible for rejecting token requests made by + /// applications that haven't been granted the appropriate resource permissions. + /// Note: this handler is not used when the degraded mode is enabled or when resource permissions are disabled. + /// + public sealed class ValidateResourcePermissions : IOpenIddictServerHandler + { + private readonly IOpenIddictApplicationManager _applicationManager; + + public ValidateResourcePermissions() => throw new InvalidOperationException(SR.GetResourceString(SR.ID0016)); + + public ValidateResourcePermissions(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() + .AddFilter() + .UseScopedHandler() + .SetOrder(ValidateAudiencePermissions.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public async ValueTask HandleAsync(ValidateTokenRequestContext 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 resource in context.Request.GetResources()) + { + // Reject the request if the application is not allowed to use the iterated resource. + if (!await _applicationManager.HasPermissionAsync(application, Permissions.Prefixes.Resource + resource)) + { + context.Logger.LogInformation(6279, SR.GetResourceString(SR.ID6277), context.ClientId, resource); + + context.Reject( + error: Errors.InvalidRequest, + description: SR.GetResourceString(SR.ID2192), + uri: SR.FormatID8000(SR.ID2192)); + + return; + } + } + } + } + /// /// Contains the logic responsible for rejecting token requests made by /// applications for which proof key for code exchange (PKCE) was enforced. @@ -1301,7 +1615,7 @@ public static partial class OpenIddictServerHandlers .AddFilter() .AddFilter() .UseScopedHandler() - .SetOrder(ValidateScopePermissions.Descriptor.Order + 1_000) + .SetOrder(ValidateResourcePermissions.Descriptor.Order + 1_000) .SetType(OpenIddictServerHandlerType.BuiltIn) .Build(); diff --git a/src/OpenIddict.Server/OpenIddictServerOptions.cs b/src/OpenIddict.Server/OpenIddictServerOptions.cs index e43be0d0..445c4be9 100644 --- a/src/OpenIddict.Server/OpenIddictServerOptions.cs +++ b/src/OpenIddict.Server/OpenIddictServerOptions.cs @@ -356,6 +356,16 @@ public sealed class OpenIddictServerOptions /// public bool DisableTokenStorage { get; set; } + /// + /// Gets or sets a boolean indicating whether audience validation is disabled. + /// + public bool DisableAudienceValidation { get; set; } + + /// + /// Gets or sets a boolean indicating whether resource validation is disabled. + /// + public bool DisableResourceValidation { get; set; } + /// /// Gets or sets a boolean indicating whether scope validation is disabled. /// @@ -386,6 +396,12 @@ public sealed class OpenIddictServerOptions OpenIddictConstants.TokenTypeIdentifiers.RefreshToken }; + /// + /// Gets the OAuth 2.0 audiences enabled for this application + /// (exclusively used with the OAuth 2.0 Token Exchange flow). + /// + public HashSet Audiences { get; } = new(StringComparer.Ordinal); + /// /// Gets the OAuth 2.0 client assertion types enabled for this application. /// @@ -461,6 +477,13 @@ public sealed class OpenIddictServerOptions /// public bool RequirePushedAuthorizationRequests { get; set; } + /// + /// Gets the OAuth 2.0 resources enabled for this application (typically used + /// with the OAuth 2.0 Token Exchange flow and with authorization or pushed + /// authorization requests that include one or more resource indicators). + /// + public HashSet Resources { get; } = []; + /// /// Gets the OAuth 2.0/OpenID Connect response types enabled for this application. /// @@ -508,6 +531,12 @@ public sealed class OpenIddictServerOptions [EditorBrowsable(EditorBrowsableState.Advanced)] public string DefaultRequestedTokenType { get; set; } = TokenTypeIdentifiers.AccessToken; + /// + /// Gets or sets a boolean indicating whether audience permissions should be ignored. + /// Setting this property to is NOT recommended. + /// + public bool IgnoreAudiencePermissions { get; set; } + /// /// Gets or sets a boolean indicating whether endpoint permissions should be ignored. /// Setting this property to is NOT recommended. @@ -520,6 +549,12 @@ public sealed class OpenIddictServerOptions /// public bool IgnoreGrantTypePermissions { get; set; } + /// + /// Gets or sets a boolean indicating whether resource permissions should be ignored. + /// Setting this property to is NOT recommended. + /// + public bool IgnoreResourcePermissions { get; set; } + /// /// Gets or sets a boolean indicating whether response type permissions should be ignored. /// Setting this property to is NOT recommended. diff --git a/test/OpenIddict.Abstractions.Tests/Primitives/OpenIddictExtensionsTests.cs b/test/OpenIddict.Abstractions.Tests/Primitives/OpenIddictExtensionsTests.cs index dae77881..90687d47 100644 --- a/test/OpenIddict.Abstractions.Tests/Primitives/OpenIddictExtensionsTests.cs +++ b/test/OpenIddict.Abstractions.Tests/Primitives/OpenIddictExtensionsTests.cs @@ -49,6 +49,31 @@ public class OpenIddictExtensionsTests Assert.Equal(values, request.GetAcrValues()); } + [Fact] + public void GetAudiences_ThrowsAnExceptionForNullRequest() + { + // Arrange + var request = (OpenIddictRequest) null!; + + // Act and assert + var exception = Assert.Throws(() => request.GetAudiences()); + + Assert.Equal("request", exception.ParamName); + } + + [Fact] + public void GetAudiences_ReturnsExpectedAudiences() + { + // Arrange + var request = new OpenIddictRequest + { + Audiences = ["Contoso", null, string.Empty, "Fabrikam", "Fabrikam"] + }; + + // Act and assert + Assert.Equal>(["Contoso", "Fabrikam"], request.GetAudiences()); + } + [Fact] public void GetPromptValues_ThrowsAnExceptionForNullRequest() { @@ -85,6 +110,31 @@ public class OpenIddictExtensionsTests Assert.Equal(values, request.GetPromptValues()); } + [Fact] + public void GetResources_ThrowsAnExceptionForNullRequest() + { + // Arrange + var request = (OpenIddictRequest) null!; + + // Act and assert + var exception = Assert.Throws(() => request.GetResources()); + + Assert.Equal("request", exception.ParamName); + } + + [Fact] + public void GetResources_ReturnsExpectedResources() + { + // Arrange + var request = new OpenIddictRequest + { + Resources = ["urn:contoso", null, string.Empty, "urn:fabrikam", "urn:fabrikam"] + }; + + // Act and assert + Assert.Equal>(["urn:contoso", "urn:fabrikam"], request.GetResources()); + } + [Fact] public void GetResponseTypes_ThrowsAnExceptionForNullRequest() { @@ -180,7 +230,7 @@ public class OpenIddictExtensionsTests var exception = Assert.Throws(() => request.HasAcrValue(value!)); Assert.Equal("value", exception.ParamName); - Assert.StartsWith(SR.GetResourceString(SR.ID0177), exception.Message); + Assert.StartsWith(SR.FormatID0366("value"), exception.Message); } [Theory] @@ -217,6 +267,51 @@ public class OpenIddictExtensionsTests Assert.Equal(result, request.HasAcrValue("mod-mf")); } + [Fact] + public void HasAudience_ThrowsAnExceptionForNullRequest() + { + // Arrange + var request = (OpenIddictRequest) null!; + + // Act and assert + var exception = Assert.Throws(() => + { + request.HasAudience("Contoso"); + }); + + Assert.Equal("request", exception.ParamName); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void HasAudience_ThrowsAnExceptionForNullOrEmptyAudience(string? resource) + { + // Arrange + var request = new OpenIddictRequest(); + + // Act and assert + var exception = Assert.Throws(() => request.HasAudience(resource!)); + + Assert.Equal("audience", exception.ParamName); + Assert.StartsWith(SR.FormatID0366("audience"), exception.Message); + } + + [Fact] + public void HasAudience_ReturnsExpectedResult() + { + // Arrange + var request = new OpenIddictRequest + { + Audiences = ["Contoso", null, string.Empty, "Fabrikam", "Fabrikam"] + }; + + // Act and assert + Assert.True(request.HasAudience("Contoso")); + Assert.True(request.HasAudience("Fabrikam")); + Assert.False(request.HasAudience("Northwind")); + } + [Fact] public void HasPromptValue_ThrowsAnExceptionForNullRequest() { @@ -235,16 +330,16 @@ public class OpenIddictExtensionsTests [Theory] [InlineData(null)] [InlineData("")] - public void HasPromptValue_ThrowsAnExceptionForNullOrEmptyPrompt(string? prompt) + public void HasPromptValue_ThrowsAnExceptionForNullOrEmptyPrompt(string? value) { // Arrange var request = new OpenIddictRequest(); // Act and assert - var exception = Assert.Throws(() => request.HasPromptValue(prompt!)); + var exception = Assert.Throws(() => request.HasPromptValue(value!)); - Assert.Equal("prompt", exception.ParamName); - Assert.StartsWith(SR.GetResourceString(SR.ID0178), exception.Message); + Assert.Equal("value", exception.ParamName); + Assert.StartsWith(SR.FormatID0366("value"), exception.Message); } [Theory] @@ -269,12 +364,12 @@ public class OpenIddictExtensionsTests [InlineData("LOGIN CONSENT SELECT_ACCOUNT ", false)] [InlineData("LOGIN", false)] [InlineData("LOGIN SELECT_ACCOUNT", false)] - public void HasPromptValue_ReturnsExpectedResult(string? prompt, bool result) + public void HasPromptValue_ReturnsExpectedResult(string? value, bool result) { // Arrange var request = new OpenIddictRequest { - Prompt = prompt + Prompt = value }; // Act and assert @@ -308,7 +403,7 @@ public class OpenIddictExtensionsTests var exception = Assert.Throws(() => request.HasResponseType(type!)); Assert.Equal("type", exception.ParamName); - Assert.StartsWith(SR.GetResourceString(SR.ID0179), exception.Message); + Assert.StartsWith(SR.FormatID0366("type"), exception.Message); } [Theory] @@ -345,6 +440,51 @@ public class OpenIddictExtensionsTests Assert.Equal(result, request.HasResponseType(ResponseTypes.Code)); } + [Fact] + public void HasResource_ThrowsAnExceptionForNullRequest() + { + // Arrange + var request = (OpenIddictRequest) null!; + + // Act and assert + var exception = Assert.Throws(() => + { + request.HasResource("urn:contoso"); + }); + + Assert.Equal("request", exception.ParamName); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void HasResource_ThrowsAnExceptionForNullOrEmptyResource(string? resource) + { + // Arrange + var request = new OpenIddictRequest(); + + // Act and assert + var exception = Assert.Throws(() => request.HasResource(resource!)); + + Assert.Equal("resource", exception.ParamName); + Assert.StartsWith(SR.FormatID0366("resource"), exception.Message); + } + + [Fact] + public void HasResource_ReturnsExpectedResult() + { + // Arrange + var request = new OpenIddictRequest + { + Resources = ["urn:contoso", null, string.Empty, "urn:fabrikam", "urn:fabrikam"] + }; + + // Act and assert + Assert.True(request.HasResource("urn:contoso")); + Assert.True(request.HasResource("urn:fabrikam")); + Assert.False(request.HasResource("urn:northwind")); + } + [Fact] public void HasScope_ThrowsAnExceptionForNullRequest() { @@ -372,7 +512,7 @@ public class OpenIddictExtensionsTests var exception = Assert.Throws(() => request.HasScope(scope!)); Assert.Equal("scope", exception.ParamName); - Assert.StartsWith(SR.GetResourceString(SR.ID0180), exception.Message); + Assert.StartsWith(SR.FormatID0366("scope"), exception.Message); } [Theory] diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Authentication.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Authentication.cs index 781c0864..c80206e7 100644 --- a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Authentication.cs +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Authentication.cs @@ -415,6 +415,57 @@ public abstract partial class OpenIddictServerIntegrationTests Assert.Equal(SR.FormatID8000(SR.ID2033), response.ErrorUri); } + [Fact] + public async Task ValidateAuthorizationRequest_ForbiddenAudienceCausesAnError() + { + // 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 + { + Audiences = ["Contoso"], + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = "code id_token token", + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.FormatID2193(Parameters.Audience), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2193), response.ErrorUri); + } + + [Theory] + [InlineData("fabrikam", SR.ID2030)] + [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)] + [InlineData("urn:fabrikam#param", SR.ID2031)] + public async Task ValidateAuthorizationRequest_InvalidResourceCausesAnError(string resource, 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/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + Resources = [resource], + ResponseType = ResponseTypes.Code + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(string.Format(SR.GetResourceString(message), Parameters.Resource), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(message), response.ErrorUri); + } + [Theory] [InlineData("code id_token")] [InlineData("code id_token token")] @@ -1017,6 +1068,82 @@ public abstract partial class OpenIddictServerIntegrationTests Assert.Equal(SR.FormatID8000(SR.ID2035), response.ErrorUri); } + [Fact] + public async Task ValidateAuthorizationRequest_RequestIsRejectedWhenUnregisteredResourceIsSpecified() + { + // 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); + })); + }); + + 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", + Resources = ["urn:unregistered_resource"], + ResponseType = ResponseTypes.Code, + }); + + // Assert + Assert.Equal(Errors.InvalidTarget, response.Error); + Assert.Equal(SR.FormatID2190(Parameters.Resource), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2190), response.ErrorUri); + } + + [Fact] + public async Task ValidateAuthorizationRequest_RequestIsValidatedWhenResourceRegisteredInOptionsIsSpecified() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + options.RegisterResources("urn:registered_resource"); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Principal = new ClaimsPrincipal(new ClaimsIdentity("Bearer")) + .SetClaim(Claims.Subject, "Bob le Magnifique"); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + Nonce = "n-0S6_WzA2Mj", + RedirectUri = "http://www.fabrikam.com/path", + Resources = ["urn:registered_resource"], + ResponseType = ResponseTypes.Token + }); + + // Assert + Assert.Null(response.Error); + Assert.Null(response.ErrorDescription); + Assert.Null(response.ErrorUri); + Assert.NotNull(response.AccessToken); + } + [Fact] public async Task ValidateAuthorizationRequest_UnknownResponseModeParameterIsRejected() { @@ -3775,6 +3902,73 @@ public abstract partial class OpenIddictServerIntegrationTests Assert.Equal(SR.FormatID8000(SR.ID2035), response.ErrorUri); } + [Fact] + public async Task ValidatePushedAuthorizationRequest_RequestIsRejectedWhenUnregisteredResourceIsSpecified() + { + // 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); + })); + }); + + 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", + Resources = ["urn:registered_resource"], + ResponseType = ResponseTypes.Code + }); + + // Assert + Assert.Equal(Errors.InvalidTarget, response.Error); + Assert.Equal(SR.FormatID2190(Parameters.Resource), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2190), response.ErrorUri); + } + + [Fact] + public async Task ValidatePushedAuthorizationRequest_RequestIsValidatedWhenResourceRegisteredInOptionsIsSpecified() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + options.RegisterResources("urn:registered_resource"); + }); + + 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", + Resources = ["urn:registered_resource"], + ResponseType = ResponseTypes.Token + }); + + // Assert + Assert.Null(response.Error); + Assert.Null(response.ErrorDescription); + Assert.Null(response.ErrorUri); + Assert.NotNull(response.RequestUri); + } + [Fact] public async Task ValidatePushedAuthorizationRequest_UnknownResponseModeParameterIsRejected() { @@ -4557,6 +4751,58 @@ public abstract partial class OpenIddictServerIntegrationTests Permissions.Prefixes.Scope + Scopes.Email, It.IsAny()), Times.Once()); } + [Fact] + public async Task ValidatePushedAuthorizationRequest_RequestIsRejectedWhenResourcePermissionIsNotGranted() + { + // 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.Resource + "urn:contoso", It.IsAny())) + .ReturnsAsync(true); + + mock.Setup(manager => manager.HasPermissionAsync(application, + Permissions.Prefixes.Resource + "urn:fabrikam", It.IsAny())) + .ReturnsAsync(false); + }); + + await using var server = await CreateServerAsync(options => + { + options.Services.AddSingleton(manager); + options.RegisterResources("urn:contoso", "urn:fabrikam"); + options.Configure(options => options.IgnoreResourcePermissions = 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", + Resources = ["urn:contoso", "urn:fabrikam"], + ResponseType = ResponseTypes.Code + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.GetResourceString(SR.ID2192), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2192), response.ErrorUri); + + Mock.Get(manager).Verify(manager => manager.HasPermissionAsync(application, + Permissions.Prefixes.Resource + "urn:contoso", It.IsAny()), Times.Once()); + Mock.Get(manager).Verify(manager => manager.HasPermissionAsync(application, + Permissions.Prefixes.Resource + "urn:fabrikam", It.IsAny()), Times.Once()); + } + [Fact] public async Task ValidatePushedAuthorizationRequest_RequestIsRejectedWhenCodeChallengeIsMissingWithPkceFeatureEnforced() { diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Exchange.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Exchange.cs index 26199107..d8d4d4d9 100644 --- a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Exchange.cs +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Exchange.cs @@ -547,6 +547,56 @@ public abstract partial class OpenIddictServerIntegrationTests Assert.Equal(SR.FormatID8000(SR.ID2074), response.ErrorUri); } + [Fact] + public async Task ValidateTokenRequest_ForbiddenAudienceCausesAnError() + { + // Arrange + await using var server = await CreateServerAsync(options => options.EnableDegradedMode()); + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/token", new OpenIddictRequest + { + Audiences = ["Contoso"], + ClientId = "Fabrikam", + DeviceCode = "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS", + GrantType = GrantTypes.DeviceCode + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.FormatID2195(Parameters.Audience), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2195), response.ErrorUri); + } + + [Theory] + [InlineData("fabrikam", SR.ID2030)] + [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)] + [InlineData("urn:fabrikam#param", SR.ID2031)] + public async Task ValidateTokenRequest_InvalidResourceCausesAnError(string resource, 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/token", new OpenIddictRequest + { + GrantType = GrantTypes.TokenExchange, + Resources = [resource], + SubjectToken = "accVkjcJyb4BWCxGsndESCJQbdFMogUC5PbRDqceLTC", + SubjectTokenType = TokenTypeIdentifiers.AccessToken + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(string.Format(SR.GetResourceString(message), Parameters.Resource), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(message), response.ErrorUri); + } + [Fact] public async Task ValidateTokenRequest_InvalidAuthorizationCodeCausesAnError() { @@ -2130,6 +2180,140 @@ public abstract partial class OpenIddictServerIntegrationTests Assert.NotNull(response.AccessToken); } + [Fact] + public async Task ValidateTokenRequest_RequestIsRejectedWhenUnregisteredAudienceIsSpecified() + { + // Arrange + await using var server = await CreateServerAsync(); + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/token", new OpenIddictRequest + { + Audiences = ["unregistered_audience"], + GrantType = GrantTypes.TokenExchange, + SubjectToken = "8xLOxBtZp8", + SubjectTokenType = TokenTypeIdentifiers.RefreshToken + }); + + // Assert + Assert.Equal(Errors.InvalidTarget, response.Error); + Assert.Equal(SR.FormatID2190(Parameters.Audience), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2190), response.ErrorUri); + } + + [Fact] + public async Task ValidateTokenRequest_RequestIsValidatedWhenAudienceRegisteredInOptionsIsSpecified() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + options.RegisterAudiences("registered_audience"); + + options.AddEventHandler(builder => + { + builder.UseInlineHandler(context => + { + Assert.Equal("8xLOxBtZp8", context.Token); + Assert.Equal([TokenTypeIdentifiers.RefreshToken], context.ValidTokenTypes); + + context.Principal = new ClaimsPrincipal(new ClaimsIdentity("Bearer")) + .SetTokenType(TokenTypeIdentifiers.RefreshToken) + .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/token", new OpenIddictRequest + { + Audiences = ["registered_audience"], + GrantType = GrantTypes.TokenExchange, + SubjectToken = "8xLOxBtZp8", + SubjectTokenType = TokenTypeIdentifiers.RefreshToken + }); + + // Assert + Assert.Null(response.Error); + Assert.Null(response.ErrorDescription); + Assert.Null(response.ErrorUri); + Assert.NotNull(response.AccessToken); + } + + [Fact] + public async Task ValidateTokenRequest_RequestIsRejectedWhenUnregisteredResourceIsSpecified() + { + // Arrange + await using var server = await CreateServerAsync(); + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/token", new OpenIddictRequest + { + GrantType = GrantTypes.TokenExchange, + Resources = ["urn:unregistered_resource"], + SubjectToken = "8xLOxBtZp8", + SubjectTokenType = TokenTypeIdentifiers.RefreshToken + }); + + // Assert + Assert.Equal(Errors.InvalidTarget, response.Error); + Assert.Equal(SR.FormatID2190(Parameters.Resource), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2190), response.ErrorUri); + } + + [Fact] + public async Task ValidateTokenRequest_RequestIsValidatedWhenResourceRegisteredInOptionsIsSpecified() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + options.RegisterResources("urn:registered_resource"); + + options.AddEventHandler(builder => + { + builder.UseInlineHandler(context => + { + Assert.Equal("8xLOxBtZp8", context.Token); + Assert.Equal([TokenTypeIdentifiers.RefreshToken], context.ValidTokenTypes); + + context.Principal = new ClaimsPrincipal(new ClaimsIdentity("Bearer")) + .SetTokenType(TokenTypeIdentifiers.RefreshToken) + .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/token", new OpenIddictRequest + { + GrantType = GrantTypes.TokenExchange, + Resources = ["urn:registered_resource"], + SubjectToken = "8xLOxBtZp8", + SubjectTokenType = TokenTypeIdentifiers.RefreshToken + }); + + // Assert + Assert.Null(response.Error); + Assert.Null(response.ErrorDescription); + Assert.Null(response.ErrorUri); + Assert.NotNull(response.AccessToken); + } + [Fact] public async Task ValidateTokenRequest_RequestIsRejectedWhenClientAssertionIsSpecifiedWithoutType() { @@ -2679,6 +2863,148 @@ public abstract partial class OpenIddictServerIntegrationTests Permissions.Prefixes.Scope + Scopes.Email, It.IsAny()), Times.Once()); } + [Fact] + public async Task ValidateTokenRequest_RequestIsRejectedWhenAudiencePermissionIsNotGranted() + { + // 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.Public, It.IsAny())) + .ReturnsAsync(true); + + mock.Setup(manager => manager.HasPermissionAsync(application, + Permissions.Prefixes.Audience + "Contoso", It.IsAny())) + .ReturnsAsync(true); + + mock.Setup(manager => manager.HasPermissionAsync(application, + Permissions.Prefixes.Audience + "Fabrikam", It.IsAny())) + .ReturnsAsync(false); + }); + + await using var server = await CreateServerAsync(options => + { + options.AddEventHandler(builder => + { + builder.UseInlineHandler(context => + { + Assert.Equal("8xLOxBtZp8", context.Token); + Assert.Equal([TokenTypeIdentifiers.RefreshToken], context.ValidTokenTypes); + + context.Principal = new ClaimsPrincipal(new ClaimsIdentity("Bearer")) + .SetTokenType(TokenTypeIdentifiers.RefreshToken) + .SetClaim(Claims.Subject, "Bob le Bricoleur"); + + return default; + }); + + builder.SetOrder(ValidateIdentityModelToken.Descriptor.Order - 500); + }); + + options.Services.AddSingleton(manager); + + options.RegisterAudiences("Contoso", "Fabrikam"); + options.Configure(options => options.IgnoreAudiencePermissions = false); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/token", new OpenIddictRequest + { + ClientId = "Fabrikam", + GrantType = GrantTypes.TokenExchange, + Audiences = ["Contoso", "Fabrikam"], + SubjectToken = "8xLOxBtZp8", + SubjectTokenType = TokenTypeIdentifiers.RefreshToken + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.GetResourceString(SR.ID2191), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2191), response.ErrorUri); + + Mock.Get(manager).Verify(manager => manager.HasPermissionAsync(application, + Permissions.Prefixes.Audience + "Contoso", It.IsAny()), Times.Once()); + Mock.Get(manager).Verify(manager => manager.HasPermissionAsync(application, + Permissions.Prefixes.Audience + "Fabrikam", It.IsAny()), Times.Once()); + } + + [Fact] + public async Task ValidateTokenRequest_RequestIsRejectedWhenResourcePermissionIsNotGranted() + { + // 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.Public, It.IsAny())) + .ReturnsAsync(true); + + mock.Setup(manager => manager.HasPermissionAsync(application, + Permissions.Prefixes.Resource + "urn:contoso", It.IsAny())) + .ReturnsAsync(true); + + mock.Setup(manager => manager.HasPermissionAsync(application, + Permissions.Prefixes.Resource + "urn:fabrikam", It.IsAny())) + .ReturnsAsync(false); + }); + + await using var server = await CreateServerAsync(options => + { + options.AddEventHandler(builder => + { + builder.UseInlineHandler(context => + { + Assert.Equal("8xLOxBtZp8", context.Token); + Assert.Equal([TokenTypeIdentifiers.RefreshToken], context.ValidTokenTypes); + + context.Principal = new ClaimsPrincipal(new ClaimsIdentity("Bearer")) + .SetTokenType(TokenTypeIdentifiers.RefreshToken) + .SetClaim(Claims.Subject, "Bob le Bricoleur"); + + return default; + }); + + builder.SetOrder(ValidateIdentityModelToken.Descriptor.Order - 500); + }); + + options.Services.AddSingleton(manager); + + options.RegisterResources("urn:contoso", "urn:fabrikam"); + options.Configure(options => options.IgnoreResourcePermissions = false); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/token", new OpenIddictRequest + { + ClientId = "Fabrikam", + GrantType = GrantTypes.TokenExchange, + Resources = ["urn:contoso", "urn:fabrikam"], + SubjectToken = "8xLOxBtZp8", + SubjectTokenType = TokenTypeIdentifiers.RefreshToken + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.GetResourceString(SR.ID2192), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2192), response.ErrorUri); + + Mock.Get(manager).Verify(manager => manager.HasPermissionAsync(application, + Permissions.Prefixes.Resource + "urn:contoso", It.IsAny()), Times.Once()); + Mock.Get(manager).Verify(manager => manager.HasPermissionAsync(application, + Permissions.Prefixes.Resource + "urn:fabrikam", It.IsAny()), Times.Once()); + } + [Fact] public async Task ValidateTokenRequest_RequestIsRejectedWhenCodeVerifierIsMissingWithPkceFeatureEnforced() { diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs index 751fae71..c3b0a135 100644 --- a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs @@ -4123,8 +4123,10 @@ public abstract partial class OpenIddictServerIntegrationTests options.AcceptAnonymousClients(); // Disable permission enforcement by default. - options.IgnoreEndpointPermissions() + options.IgnoreAudiencePermissions() + .IgnoreEndpointPermissions() .IgnoreGrantTypePermissions() + .IgnoreResourcePermissions() .IgnoreResponseTypePermissions() .IgnoreScopePermissions(); diff --git a/test/OpenIddict.Server.Tests/OpenIddictServerBuilderTests.cs b/test/OpenIddict.Server.Tests/OpenIddictServerBuilderTests.cs index c4e39e8a..6507b35a 100644 --- a/test/OpenIddict.Server.Tests/OpenIddictServerBuilderTests.cs +++ b/test/OpenIddict.Server.Tests/OpenIddictServerBuilderTests.cs @@ -670,6 +670,22 @@ public class OpenIddictServerBuilderTests Assert.True(options.DisableAccessTokenEncryption); } + [Fact] + public void DisableAudienceValidation_AudienceValidationIsDisabled() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act + builder.DisableAudienceValidation(); + + var options = GetOptions(services); + + // Assert + Assert.True(options.DisableAudienceValidation); + } + [Fact] public void DisableAuthorizationStorage_AuthorizationStorageIsDisabled() { @@ -686,6 +702,22 @@ public class OpenIddictServerBuilderTests Assert.True(options.DisableAuthorizationStorage); } + [Fact] + public void DisableResourceValidation_ResourceValidationIsDisabled() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act + builder.DisableResourceValidation(); + + var options = GetOptions(services); + + // Assert + Assert.True(options.DisableResourceValidation); + } + [Fact] public void DisableRollingRefreshTokens_RollingRefreshTokensAreDisabled() { @@ -750,6 +782,86 @@ public class OpenIddictServerBuilderTests Assert.True(options.DisableTokenStorage); } + [Fact] + public void IgnoreAudiencePermissions_AudiencePermissionsAreIgnored() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act + builder.IgnoreAudiencePermissions(); + + var options = GetOptions(services); + + // Assert + Assert.True(options.IgnoreAudiencePermissions); + } + + [Fact] + public void IgnoreGrantTypePermissions_GrantTypePermissionsAreIgnored() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act + builder.IgnoreGrantTypePermissions(); + + var options = GetOptions(services); + + // Assert + Assert.True(options.IgnoreGrantTypePermissions); + } + + [Fact] + public void IgnoreScopePermissions_ScopePermissionsAreIgnored() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act + builder.IgnoreScopePermissions(); + + var options = GetOptions(services); + + // Assert + Assert.True(options.IgnoreScopePermissions); + } + + [Fact] + public void IgnoreResourcePermissions_ResourcePermissionsAreIgnored() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act + builder.IgnoreResourcePermissions(); + + var options = GetOptions(services); + + // Assert + Assert.True(options.IgnoreResourcePermissions); + } + + [Fact] + public void IgnoreResponseTypePermissions_ResponseTypePermissionsAreIgnored() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act + builder.IgnoreResponseTypePermissions(); + + var options = GetOptions(services); + + // Assert + Assert.True(options.IgnoreResponseTypePermissions); + } + [Fact] public void RequireProofKeyForCodeExchange_PkceIsEnforced() { @@ -2032,6 +2144,51 @@ public class OpenIddictServerBuilderTests Assert.Equal(new Uri("http://www.fabrikam.com/"), options.Issuer); } + [Fact] + public void RegisterAudiences_AudiencesAreAdded() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act + builder.RegisterAudiences("custom_audience_1", "custom_audience_2"); + + var options = GetOptions(services); + + // Assert + Assert.Contains("custom_audience_1", options.Audiences); + Assert.Contains("custom_audience_2", options.Audiences); + } + + [Fact] + public void RegisterAudiences_ThrowsAnExceptionForNullAudiences() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => builder.RegisterAudiences(audiences: null!)); + Assert.Equal("audiences", exception.ParamName); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void RegisterAudiences_ThrowsAnExceptionForNullOrEmptyAudience(string? audience) + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => builder.RegisterAudiences([audience!])); + + Assert.Equal("audiences", exception.ParamName); + Assert.Contains(SR.FormatID0457("audiences"), exception.Message); + } + [Fact] public void RegisterClaims_ClaimsAreAdded() { @@ -2122,6 +2279,52 @@ public class OpenIddictServerBuilderTests Assert.Contains(SR.FormatID0457("values"), exception.Message); } + [Fact] + public void RegisterResources_ResourcesAreAdded() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act + builder.RegisterResources("urn:custom_resource_1", "urn:custom_resource_2"); + + var options = GetOptions(services); + + // Assert + Assert.Contains(new Uri("urn:custom_resource_1", UriKind.Absolute), options.Resources); + Assert.Contains(new Uri("urn:custom_resource_2", UriKind.Absolute), options.Resources); + } + + [Fact] + public void RegisterResources_ThrowsAnExceptionForNullResources() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => builder.RegisterResources((Uri[]) null!)); + Assert.Equal("resources", exception.ParamName); + } + + [Theory] + [InlineData("C:\\tmp\\file.xml")] + [InlineData("http://www.fabrikam.com/path#param=value")] + [InlineData("urn:fabrikam#param")] + public void RegisterResources_ThrowsAnExceptionForInvalidResources(string? resource) + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => builder.RegisterResources([resource!])); + + Assert.Equal("resources", exception.ParamName); + Assert.Contains(SR.FormatID0495("resources"), exception.Message); + } + [Fact] public void RegisterScopes_ScopesAreAdded() {