From b33dad15f37ccfc4228217efdd4fa84f54e4d9e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Chalet?= Date: Fri, 30 May 2025 20:29:53 +0200 Subject: [PATCH] Implement new audience and presenter validation logic as part of the ValidateToken event --- .../OpenIddictHelpers.cs | 29 ++++ .../OpenIddictResources.resx | 24 ++- .../OpenIddictClientEvents.Protection.cs | 22 +++ .../OpenIddictClientExtensions.cs | 3 + .../OpenIddictClientHandlerFilters.cs | 51 +++++++ .../OpenIddictClientHandlers.Protection.cs | 139 ++++++++++++++++- .../OpenIddictClientHandlers.cs | 22 ++- .../OpenIddictServerConfiguration.cs | 2 +- .../OpenIddictServerEvents.Protection.cs | 22 +++ .../OpenIddictServerExtensions.cs | 2 + .../OpenIddictServerHandlerFilters.cs | 34 +++++ ...OpenIddictServerHandlers.Authentication.cs | 6 +- .../OpenIddictServerHandlers.Device.cs | 4 +- .../OpenIddictServerHandlers.Exchange.cs | 2 +- .../OpenIddictServerHandlers.Protection.cs | 144 +++++++++++++++++- .../OpenIddictServerHandlers.Session.cs | 4 +- .../OpenIddictServerHandlers.Userinfo.cs | 6 +- .../OpenIddictServerHandlers.cs | 77 +++++++++- .../OpenIddictValidationEvents.Protection.cs | 27 ++++ .../OpenIddictValidationExtensions.cs | 3 + .../OpenIddictValidationHandlerFilters.cs | 51 +++++++ ...OpenIddictValidationHandlers.Protection.cs | 104 ++++++++++--- .../OpenIddictValidationHandlers.cs | 10 +- ...enIddictServerIntegrationTests.Userinfo.cs | 2 +- .../OpenIddictValidationIntegrationTests.cs | 119 +++++++++++++++ 25 files changed, 841 insertions(+), 68 deletions(-) diff --git a/shared/OpenIddict.Extensions/OpenIddictHelpers.cs b/shared/OpenIddict.Extensions/OpenIddictHelpers.cs index 6844c0b1..12557599 100644 --- a/shared/OpenIddict.Extensions/OpenIddictHelpers.cs +++ b/shared/OpenIddict.Extensions/OpenIddictHelpers.cs @@ -53,6 +53,35 @@ internal static class OpenIddictHelpers } } + /// + /// Determines whether the specified array contains at least one value present in the specified set. + /// + /// The type of the elements. + /// The array. + /// The set. + /// + /// if the specified array contains at least one + /// value present in the specified set, otherwise. + /// + public static bool IncludesAnyFromSet(IReadOnlyList array, ISet set) + { + if (set is null) + { + throw new ArgumentNullException(nameof(set)); + } + + for (var index = 0; index < array.Count; index++) + { + var value = array[index]; + if (set.Contains(value)) + { + return true; + } + } + + return false; + } + #if !SUPPORTS_TASK_WAIT_ASYNC /// /// Waits until the specified task returns a result or the cancellation token is signaled. diff --git a/src/OpenIddict.Abstractions/OpenIddictResources.resx b/src/OpenIddict.Abstractions/OpenIddictResources.resx index 8fa5fc50..b2fff355 100644 --- a/src/OpenIddict.Abstractions/OpenIddictResources.resx +++ b/src/OpenIddict.Abstractions/OpenIddictResources.resx @@ -2029,7 +2029,7 @@ Alternatively, any value respecting the '[region]-[subregion]-[identifier]' patt The specified token doesn't contain any audience. - The specified token cannot be used with this resource server. + The specified token doesn't contain any valid audience, which may indicate that it was issued to be used with another application. The user represented by the token is not allowed to perform the requested action. @@ -2295,6 +2295,12 @@ Alternatively, any value respecting the '[region]-[subregion]-[identifier]' patt This client application is not allowed to use the pushed authorization request endpoint. + + The specified token doesn't contain any presenter. + + + The specified token doesn't contain any valid presenter, which may indicate that it was issued to a different client. + The '{0}' parameter shouldn't be null or empty at this point. @@ -2736,10 +2742,10 @@ The principal used to create the token contained the following claims: {Claims}. The authentication demand was rejected because the token was expired. - The authentication demand was rejected because the token had no audience attached. + The authentication demand was rejected because the token validated via introspection had no audience attached. - The authentication demand was rejected because the token had no valid audience. + The authentication demand was rejected because the token validated via introspection had no valid audience. Client authentication cannot be enforced for public applications. @@ -3048,6 +3054,18 @@ This may indicate that the hashed entry is corrupted or malformed. The pushed authorization request was rejected because the identity token used as a hint was issued to a different client. + + The token was rejected because it had no presenter attached and at least one explicit presenter was expected. + + + The token was rejected because it had no valid presenter. + + + The token was rejected because it had no audience attached and at least one explicit audience was expected. + + + The token was rejected because it had no valid audience. + https://documentation.openiddict.com/errors/{0} diff --git a/src/OpenIddict.Client/OpenIddictClientEvents.Protection.cs b/src/OpenIddict.Client/OpenIddictClientEvents.Protection.cs index 6d4f4949..d8a365cb 100644 --- a/src/OpenIddict.Client/OpenIddictClientEvents.Protection.cs +++ b/src/OpenIddict.Client/OpenIddictClientEvents.Protection.cs @@ -123,11 +123,21 @@ public static partial class OpenIddictClientEvents set => Transaction.Request = value; } + /// + /// Gets or sets a boolean indicating whether audience validation is disabled. + /// + public bool DisableAudienceValidation { get; set; } + /// /// Gets or sets a boolean indicating whether lifetime validation is disabled. /// public bool DisableLifetimeValidation { get; set; } + /// + /// Gets or sets a boolean indicating whether presenter validation is disabled. + /// + public bool DisablePresenterValidation { get; set; } + /// /// Gets or sets the security token handler used to validate the token. /// @@ -173,6 +183,18 @@ public static partial class OpenIddictClientEvents /// public HashSet AllowedCharset { get; } = new(StringComparer.Ordinal); + /// + /// Gets the audiences that are considered valid. If no value + /// is explicitly specified, all audiences are considered valid. + /// + public HashSet ValidAudiences { get; } = new(StringComparer.Ordinal); + + /// + /// Gets the presenters that are considered valid. If no value + /// is explicitly specified, all presenters are considered valid. + /// + public HashSet ValidPresenters { get; } = new(StringComparer.Ordinal); + /// /// Gets the token types that are considered valid. If no value is /// explicitly specified, all supported tokens are considered valid. diff --git a/src/OpenIddict.Client/OpenIddictClientExtensions.cs b/src/OpenIddict.Client/OpenIddictClientExtensions.cs index dd5f4146..3c7f2851 100644 --- a/src/OpenIddict.Client/OpenIddictClientExtensions.cs +++ b/src/OpenIddict.Client/OpenIddictClientExtensions.cs @@ -63,9 +63,12 @@ public static class OpenIddictClientExtensions builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); diff --git a/src/OpenIddict.Client/OpenIddictClientHandlerFilters.cs b/src/OpenIddict.Client/OpenIddictClientHandlerFilters.cs index 35b58edc..eb55c981 100644 --- a/src/OpenIddict.Client/OpenIddictClientHandlerFilters.cs +++ b/src/OpenIddict.Client/OpenIddictClientHandlerFilters.cs @@ -490,6 +490,23 @@ public static class OpenIddictClientHandlerFilters } } + /// + /// Represents a filter that excludes the associated handlers if token audience validation was disabled. + /// + public sealed class RequireTokenAudienceValidationEnabled : IOpenIddictClientHandlerFilter + { + /// + public ValueTask IsActiveAsync(ValidateTokenContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + return new(!context.DisableAudienceValidation); + } + } + /// /// Represents a filter that excludes the associated handlers if no token entry is created in the database. /// @@ -524,6 +541,23 @@ public static class OpenIddictClientHandlerFilters } } + /// + /// Represents a filter that excludes the associated handlers if token lifetime validation was disabled. + /// + public sealed class RequireTokenLifetimeValidationEnabled : IOpenIddictClientHandlerFilter + { + /// + public ValueTask IsActiveAsync(ValidateTokenContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + return new(!context.DisableLifetimeValidation); + } + } + /// /// Represents a filter that excludes the associated handlers if the token payload is not persisted in the database. /// @@ -541,6 +575,23 @@ public static class OpenIddictClientHandlerFilters } } + /// + /// Represents a filter that excludes the associated handlers if token presenter validation was disabled. + /// + public sealed class RequireTokenPresenterValidationEnabled : IOpenIddictClientHandlerFilter + { + /// + public ValueTask IsActiveAsync(ValidateTokenContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + return new(!context.DisablePresenterValidation); + } + } + /// /// Represents a filter that excludes the associated handlers if no token request is expected to be sent. /// diff --git a/src/OpenIddict.Client/OpenIddictClientHandlers.Protection.cs b/src/OpenIddict.Client/OpenIddictClientHandlers.Protection.cs index 93c9ee4a..f0dcbc88 100644 --- a/src/OpenIddict.Client/OpenIddictClientHandlers.Protection.cs +++ b/src/OpenIddict.Client/OpenIddictClientHandlers.Protection.cs @@ -32,6 +32,8 @@ public static partial class OpenIddictClientHandlers RestoreTokenEntryProperties.Descriptor, ValidatePrincipal.Descriptor, ValidateExpirationDate.Descriptor, + ValidatePresenters.Descriptor, + ValidateAudiences.Descriptor, ValidateTokenEntry.Descriptor, /* @@ -606,7 +608,7 @@ public static partial class OpenIddictClientHandlers } /// - /// Contains the logic responsible for rejecting authentication demands for which no valid principal was resolved. + /// Contains the logic responsible for rejecting tokens for which no valid principal could be resolved. /// public sealed class ValidatePrincipal : IOpenIddictClientHandler { @@ -648,7 +650,7 @@ public static partial class OpenIddictClientHandlers throw new InvalidOperationException(SR.GetResourceString(SR.ID0004)); } - if (context.ValidTokenTypes.Count > 0 && !context.ValidTokenTypes.Contains(type)) + if (context.ValidTokenTypes.Count is > 0 && !context.ValidTokenTypes.Contains(type)) { throw new InvalidOperationException(SR.FormatID0005(type, string.Join(", ", context.ValidTokenTypes))); } @@ -658,7 +660,7 @@ public static partial class OpenIddictClientHandlers } /// - /// Contains the logic responsible for rejecting authentication demands that use an expired token. + /// Contains the logic responsible for rejecting expired tokens. /// public sealed class ValidateExpirationDate : IOpenIddictClientHandler { @@ -667,6 +669,7 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() .UseSingletonHandler() .SetOrder(ValidatePrincipal.Descriptor.Order + 1_000) .SetType(OpenIddictClientHandlerType.BuiltIn) @@ -698,7 +701,133 @@ public static partial class OpenIddictClientHandlers } /// - /// Contains the logic responsible for authentication demands a token whose + /// Contains the logic responsible for rejecting tokens that can't be used by the caller. + /// + public sealed class ValidatePresenters : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(ValidateExpirationDate.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ValidateTokenContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(context.Principal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); + + // If no specific value is expected, skip the default presenter validation. + if (context.ValidPresenters.Count is 0) + { + return default; + } + + // If the token doesn't have any presenter attached, return an error. + var presenters = context.Principal.GetPresenters(); + if (presenters.IsDefaultOrEmpty) + { + context.Logger.LogInformation(6264, SR.GetResourceString(SR.ID6264)); + + context.Reject( + error: Errors.InvalidToken, + description: SR.GetResourceString(SR.ID2184), + uri: SR.FormatID8000(SR.ID2184)); + + return default; + } + + // If the token doesn't include any registered presenter, return an error. + if (!OpenIddictHelpers.IncludesAnyFromSet(presenters, context.ValidPresenters)) + { + context.Logger.LogInformation(6265, SR.GetResourceString(SR.ID6265)); + + context.Reject( + error: Errors.InvalidToken, + description: SR.GetResourceString(SR.ID2185), + uri: SR.FormatID8000(SR.ID2185)); + + return default; + } + + return default; + } + } + + /// + /// Contains the logic responsible for rejecting tokens issued for different recipients. + /// + public sealed class ValidateAudiences : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(ValidatePresenters.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ValidateTokenContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(context.Principal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); + + // If no specific value is expected, skip the default audience validation. + if (context.ValidAudiences.Count is 0) + { + return default; + } + + // If the token doesn't have any audience attached, return an error. + var audiences = context.Principal.GetAudiences(); + if (audiences.IsDefaultOrEmpty) + { + context.Logger.LogInformation(6266, SR.GetResourceString(SR.ID6266)); + + context.Reject( + error: Errors.InvalidToken, + description: SR.GetResourceString(SR.ID2093), + uri: SR.FormatID8000(SR.ID2093)); + + return default; + } + + // If the token doesn't include any registered audience, return an error. + if (!OpenIddictHelpers.IncludesAnyFromSet(audiences, context.ValidAudiences)) + { + context.Logger.LogInformation(6267, SR.GetResourceString(SR.ID6267)); + + context.Reject( + error: Errors.InvalidToken, + description: SR.GetResourceString(SR.ID2094), + uri: SR.FormatID8000(SR.ID2094)); + + return default; + } + + return default; + } + } + + /// + /// Contains the logic responsible for rejecting tokens whose /// associated token entry is no longer valid (e.g was revoked). /// Note: this handler is not used when token storage is disabled. /// @@ -719,7 +848,7 @@ public static partial class OpenIddictClientHandlers .AddFilter() .AddFilter() .UseScopedHandler() - .SetOrder(ValidateExpirationDate.Descriptor.Order + 1_000) + .SetOrder(ValidateAudiences.Descriptor.Order + 1_000) .SetType(OpenIddictClientHandlerType.BuiltIn) .Build(); diff --git a/src/OpenIddict.Client/OpenIddictClientHandlers.cs b/src/OpenIddict.Client/OpenIddictClientHandlers.cs index a4cbb97b..b4bc227b 100644 --- a/src/OpenIddict.Client/OpenIddictClientHandlers.cs +++ b/src/OpenIddict.Client/OpenIddictClientHandlers.cs @@ -683,6 +683,8 @@ public static partial class OpenIddictClientHandlers var notification = new ValidateTokenContext(context.Transaction) { + DisableAudienceValidation = true, + DisablePresenterValidation = true, Token = context.StateToken, ValidTokenTypes = { TokenTypeIdentifiers.Private.StateToken } }; @@ -1625,6 +1627,9 @@ public static partial class OpenIddictClientHandlers var notification = new ValidateTokenContext(context.Transaction) { + // Note: for identity tokens, audience validation is enforced by a specialized handler. + DisableAudienceValidation = true, + DisablePresenterValidation = true, Token = context.FrontchannelIdentityToken, ValidTokenTypes = { TokenTypeIdentifiers.IdentityToken } }; @@ -2458,7 +2463,7 @@ public static partial class OpenIddictClientHandlers string value => value }; - if (context.Scopes.Count > 0 && + if (context.Scopes.Count is > 0 && context.TokenRequest.GrantType is not (GrantTypes.AuthorizationCode or GrantTypes.DeviceCode)) { // Note: the final OAuth 2.0 specification requires using a space as the scope separator. @@ -3061,6 +3066,9 @@ public static partial class OpenIddictClientHandlers var notification = new ValidateTokenContext(context.Transaction) { + // Note: for identity tokens, audience validation is enforced by a specialized handler. + DisableAudienceValidation = true, + DisablePresenterValidation = true, Token = context.BackchannelIdentityToken, ValidTokenTypes = { TokenTypeIdentifiers.IdentityToken } }; @@ -5019,7 +5027,7 @@ public static partial class OpenIddictClientHandlers } // If an explicit set of scopes was specified, don't overwrite it. - if (context.Scopes.Count > 0) + if (context.Scopes.Count is > 0) { return default; } @@ -5497,7 +5505,7 @@ public static partial class OpenIddictClientHandlers context.Request.ResponseType = context.ResponseType; context.Request.ResponseMode = context.ResponseMode; - if (context.Scopes.Count > 0) + if (context.Scopes.Count is > 0) { // Note: the final OAuth 2.0 specification requires using a space as the scope separator. // Clients that need to deal with older or non-compliant implementations can register @@ -5558,7 +5566,7 @@ public static partial class OpenIddictClientHandlers throw new ArgumentNullException(nameof(context)); } - if (context.Parameters.Count > 0) + if (context.Parameters.Count is > 0) { foreach (var parameter in context.Parameters) { @@ -5734,7 +5742,7 @@ public static partial class OpenIddictClientHandlers // Attach a new request instance if necessary. context.DeviceAuthorizationRequest ??= new OpenIddictRequest(); - if (context.Scopes.Count > 0) + if (context.Scopes.Count is > 0) { // Note: the final OAuth 2.0 specification requires using a space as the scope separator. // Clients that need to deal with older or non-compliant implementations can register @@ -8586,7 +8594,7 @@ public static partial class OpenIddictClientHandlers throw new ArgumentNullException(nameof(context)); } - if (context.Parameters.Count > 0) + if (context.Parameters.Count is > 0) { foreach (var parameter in context.Parameters) { @@ -8653,7 +8661,7 @@ public static partial class OpenIddictClientHandlers throw new ArgumentNullException(nameof(context)); } - if (context.Parameters.Count > 0) + if (context.Parameters.Count is > 0) { foreach (var parameter in context.Parameters) { diff --git a/src/OpenIddict.Server/OpenIddictServerConfiguration.cs b/src/OpenIddict.Server/OpenIddictServerConfiguration.cs index 778502bd..32ed5ee4 100644 --- a/src/OpenIddict.Server/OpenIddictServerConfiguration.cs +++ b/src/OpenIddict.Server/OpenIddictServerConfiguration.cs @@ -118,7 +118,7 @@ public sealed class OpenIddictServerConfiguration : IPostConfigureOptions 0 && !options.GrantTypes.Contains(GrantTypes.DeviceCode)) + if (options.DeviceAuthorizationEndpointUris.Count is > 0 && !options.GrantTypes.Contains(GrantTypes.DeviceCode)) { throw new InvalidOperationException(SR.GetResourceString(SR.ID0084)); } diff --git a/src/OpenIddict.Server/OpenIddictServerEvents.Protection.cs b/src/OpenIddict.Server/OpenIddictServerEvents.Protection.cs index 9b5043aa..d3b13801 100644 --- a/src/OpenIddict.Server/OpenIddictServerEvents.Protection.cs +++ b/src/OpenIddict.Server/OpenIddictServerEvents.Protection.cs @@ -129,11 +129,21 @@ public static partial class OpenIddictServerEvents set => Transaction.Request = value; } + /// + /// Gets or sets a boolean indicating whether audience validation is disabled. + /// + public bool DisableAudienceValidation { get; set; } + /// /// Gets or sets a boolean indicating whether lifetime validation is disabled. /// public bool DisableLifetimeValidation { get; set; } + /// + /// Gets or sets a boolean indicating whether presenter validation is disabled. + /// + public bool DisablePresenterValidation { get; set; } + /// /// Gets or sets the security token handler used to validate the token. /// @@ -189,6 +199,18 @@ public static partial class OpenIddictServerEvents /// public HashSet AllowedCharset { get; } = new(StringComparer.Ordinal); + /// + /// Gets the audiences that are considered valid. If no value + /// is explicitly specified, all audiences are considered valid. + /// + public HashSet ValidAudiences { get; } = new(StringComparer.Ordinal); + + /// + /// Gets the presenters that are considered valid. If no value + /// is explicitly specified, all presenters are considered valid. + /// + public HashSet ValidPresenters { get; } = new(StringComparer.Ordinal); + /// /// Gets the token types that are considered valid. /// diff --git a/src/OpenIddict.Server/OpenIddictServerExtensions.cs b/src/OpenIddict.Server/OpenIddictServerExtensions.cs index 1e1c10c1..593d9526 100644 --- a/src/OpenIddict.Server/OpenIddictServerExtensions.cs +++ b/src/OpenIddict.Server/OpenIddictServerExtensions.cs @@ -80,10 +80,12 @@ public static class OpenIddictServerExtensions builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); diff --git a/src/OpenIddict.Server/OpenIddictServerHandlerFilters.cs b/src/OpenIddict.Server/OpenIddictServerHandlerFilters.cs index 55e4920a..3f53a623 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlerFilters.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlerFilters.cs @@ -691,6 +691,23 @@ public static class OpenIddictServerHandlerFilters } } + /// + /// Represents a filter that excludes the associated handlers if token audience validation was disabled. + /// + public sealed class RequireTokenAudienceValidationEnabled : IOpenIddictServerHandlerFilter + { + /// + public ValueTask IsActiveAsync(ValidateTokenContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + return new(!context.DisableAudienceValidation); + } + } + /// /// Represents a filter that excludes the associated handlers if no token identifier is resolved from the token. /// @@ -759,6 +776,23 @@ public static class OpenIddictServerHandlerFilters } } + /// + /// Represents a filter that excludes the associated handlers if token presenter validation was disabled. + /// + public sealed class RequireTokenPresenterValidationEnabled : IOpenIddictServerHandlerFilter + { + /// + public ValueTask IsActiveAsync(ValidateTokenContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + return new(!context.DisablePresenterValidation); + } + } + /// /// Represents a filter that excludes the associated handlers if the request is not a token request. /// diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs index 970e763a..94ff9142 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs @@ -301,7 +301,7 @@ public static partial class OpenIddictServerHandlers Response = new OpenIddictResponse() }; - if (notification.Parameters.Count > 0) + if (notification.Parameters.Count is > 0) { foreach (var parameter in notification.Parameters) { @@ -342,7 +342,7 @@ public static partial class OpenIddictServerHandlers Response = new OpenIddictResponse() }; - if (notification.Parameters.Count > 0) + if (notification.Parameters.Count is > 0) { foreach (var parameter in notification.Parameters) { @@ -2398,7 +2398,7 @@ public static partial class OpenIddictServerHandlers Response = new OpenIddictResponse() }; - if (notification.Parameters.Count > 0) + if (notification.Parameters.Count is > 0) { foreach (var parameter in notification.Parameters) { diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Device.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Device.cs index bdb51db5..335aa124 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Device.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Device.cs @@ -251,7 +251,7 @@ public static partial class OpenIddictServerHandlers Response = new OpenIddictResponse() }; - if (notification.Parameters.Count > 0) + if (notification.Parameters.Count is > 0) { foreach (var parameter in notification.Parameters) { @@ -961,7 +961,7 @@ public static partial class OpenIddictServerHandlers Response = new OpenIddictResponse() }; - if (notification.Parameters.Count > 0) + if (notification.Parameters.Count is > 0) { foreach (var parameter in notification.Parameters) { diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Exchange.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Exchange.cs index dfd70bb2..36a9d1c3 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Exchange.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Exchange.cs @@ -252,7 +252,7 @@ public static partial class OpenIddictServerHandlers Response = new OpenIddictResponse() }; - if (notification.Parameters.Count > 0) + if (notification.Parameters.Count is > 0) { foreach (var parameter in notification.Parameters) { diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Protection.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Protection.cs index 766d40d0..f3d4c338 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Protection.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Protection.cs @@ -35,6 +35,8 @@ public static partial class OpenIddictServerHandlers RestoreTokenEntryProperties.Descriptor, ValidatePrincipal.Descriptor, ValidateExpirationDate.Descriptor, + ValidatePresenters.Descriptor, + ValidateAudiences.Descriptor, ValidateTokenEntry.Descriptor, ValidateAuthorizationEntry.Descriptor, @@ -122,8 +124,8 @@ public static partial class OpenIddictServerHandlers // Only provide a signing key resolver if the degraded mode was not enabled. // - // Applications that opt for the degraded mode and need client assertions support - // need to implement a custom event handler thats a issuer signing key resolver. + // Applications that opt for the degraded mode and need client assertions support have + // to implement a custom event handler that attaches an issuer signing key resolver. if (!context.Options.EnableDegradedMode) { if (_applicationManager is null) @@ -813,7 +815,7 @@ public static partial class OpenIddictServerHandlers } /// - /// Contains the logic responsible for rejecting authentication demands for which no valid principal was resolved. + /// Contains the logic responsible for rejecting tokens for which no valid principal could be resolved. /// public sealed class ValidatePrincipal : IOpenIddictServerHandler { @@ -886,7 +888,7 @@ public static partial class OpenIddictServerHandlers throw new InvalidOperationException(SR.GetResourceString(SR.ID0004)); } - if (context.ValidTokenTypes.Count > 0 && !context.ValidTokenTypes.Contains(type)) + if (context.ValidTokenTypes.Count is > 0 && !context.ValidTokenTypes.Contains(type)) { throw new InvalidOperationException(SR.FormatID0005(type, string.Join(", ", context.ValidTokenTypes))); } @@ -896,7 +898,7 @@ public static partial class OpenIddictServerHandlers } /// - /// Contains the logic responsible for rejecting authentication demands that use an expired token. + /// Contains the logic responsible for rejecting expired tokens. /// public sealed class ValidateExpirationDate : IOpenIddictServerHandler { @@ -956,8 +958,134 @@ public static partial class OpenIddictServerHandlers } /// - /// Contains the logic responsible for rejecting authentication demands that - /// use a token whose entry is no longer valid (e.g was revoked). + /// Contains the logic responsible for rejecting tokens that can't be used by the caller. + /// + public sealed class ValidatePresenters : IOpenIddictServerHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(ValidateExpirationDate.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ValidateTokenContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(context.Principal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); + + // If no specific value is expected, skip the default presenter validation. + if (context.ValidPresenters.Count is 0) + { + return default; + } + + // If the token doesn't have any presenter attached, return an error. + var presenters = context.Principal.GetPresenters(); + if (presenters.IsDefaultOrEmpty) + { + context.Logger.LogInformation(6264, SR.GetResourceString(SR.ID6264)); + + context.Reject( + error: Errors.InvalidToken, + description: SR.GetResourceString(SR.ID2184), + uri: SR.FormatID8000(SR.ID2184)); + + return default; + } + + // If the token doesn't include any registered presenter, return an error. + if (!OpenIddictHelpers.IncludesAnyFromSet(presenters, context.ValidPresenters)) + { + context.Logger.LogInformation(6265, SR.GetResourceString(SR.ID6265)); + + context.Reject( + error: Errors.InvalidToken, + description: SR.GetResourceString(SR.ID2185), + uri: SR.FormatID8000(SR.ID2185)); + + return default; + } + + return default; + } + } + + /// + /// Contains the logic responsible for rejecting tokens issued for different recipients. + /// + public sealed class ValidateAudiences : IOpenIddictServerHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(ValidatePresenters.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ValidateTokenContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(context.Principal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); + + // If no specific value is expected, skip the default audience validation. + if (context.ValidAudiences.Count is 0) + { + return default; + } + + // If the token doesn't have any audience attached, return an error. + var audiences = context.Principal.GetAudiences(); + if (audiences.IsDefaultOrEmpty) + { + context.Logger.LogInformation(6266, SR.GetResourceString(SR.ID6266)); + + context.Reject( + error: Errors.InvalidToken, + description: SR.GetResourceString(SR.ID2093), + uri: SR.FormatID8000(SR.ID2093)); + + return default; + } + + // If the token doesn't include any registered audience, return an error. + if (!OpenIddictHelpers.IncludesAnyFromSet(audiences, context.ValidAudiences)) + { + context.Logger.LogInformation(6267, SR.GetResourceString(SR.ID6267)); + + context.Reject( + error: Errors.InvalidToken, + description: SR.GetResourceString(SR.ID2094), + uri: SR.FormatID8000(SR.ID2094)); + + return default; + } + + return default; + } + } + + /// + /// Contains the logic responsible for rejecting tokens whose + /// associated token entry is no longer valid (e.g was revoked). /// Note: this handler is not used when the degraded mode is enabled. /// public sealed class ValidateTokenEntry : IOpenIddictServerHandler @@ -1136,7 +1264,7 @@ public static partial class OpenIddictServerHandlers } /// - /// Contains the logic responsible for authentication demands a token whose + /// Contains the logic responsible for rejecting tokens whose /// associated authorization entry is no longer valid (e.g was revoked). /// Note: this handler is not used when the degraded mode is enabled. /// diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Session.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Session.cs index 6eae3620..821d2eae 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Session.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Session.cs @@ -239,7 +239,7 @@ public static partial class OpenIddictServerHandlers Response = new OpenIddictResponse() }; - if (notification.Parameters.Count > 0) + if (notification.Parameters.Count is > 0) { foreach (var parameter in notification.Parameters) { @@ -280,7 +280,7 @@ public static partial class OpenIddictServerHandlers Response = new OpenIddictResponse() }; - if (notification.Parameters.Count > 0) + if (notification.Parameters.Count is > 0) { foreach (var parameter in notification.Parameters) { diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Userinfo.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Userinfo.cs index 26cedf26..91399c38 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Userinfo.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Userinfo.cs @@ -18,7 +18,7 @@ public static partial class OpenIddictServerHandlers public static ImmutableArray DefaultHandlers { get; } = [ /* - * UserInfo request top-level processing: + * Userinfo request top-level processing: */ ExtractUserInfoRequest.Descriptor, ValidateUserInfoRequest.Descriptor, @@ -28,13 +28,13 @@ public static partial class OpenIddictServerHandlers ApplyUserInfoResponse.Descriptor, /* - * UserInfo request validation: + * Userinfo request validation: */ ValidateAccessTokenParameter.Descriptor, ValidateAuthentication.Descriptor, /* - * UserInfo request handling: + * Userinfo request handling: */ AttachPrincipal.Descriptor, AttachAudiences.Descriptor, diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.cs index 4a19c0e7..189207a5 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.cs @@ -601,6 +601,9 @@ public static partial class OpenIddictServerHandlers var notification = new ValidateTokenContext(context.Transaction) { + // Note: for client authentication assertions, audience validation is enforced by a specialized handler. + DisableAudienceValidation = true, + DisablePresenterValidation = true, Token = context.ClientAssertion, TokenFormat = context.ClientAssertionType switch { @@ -1319,6 +1322,8 @@ public static partial class OpenIddictServerHandlers var notification = new ValidateTokenContext(context.Transaction) { + DisableAudienceValidation = true, + DisablePresenterValidation = true, Token = context.RequestToken, ValidTokenTypes = { TokenTypeIdentifiers.Private.RequestToken } }; @@ -1443,6 +1448,10 @@ public static partial class OpenIddictServerHandlers var notification = new ValidateTokenContext(context.Transaction) { + // Audience validation is deliberately disabled for the userinfo endpoint to allow any access token to + // be used even if the authorization server isn't explicitly listed as a valid audience in the token. + DisableAudienceValidation = context.EndpointType is OpenIddictServerEndpointType.UserInfo, + DisablePresenterValidation = context.EndpointType is OpenIddictServerEndpointType.UserInfo, Token = context.AccessToken, ValidTokenTypes = { TokenTypeIdentifiers.AccessToken } }; @@ -1515,10 +1524,19 @@ public static partial class OpenIddictServerHandlers var notification = new ValidateTokenContext(context.Transaction) { + DisableAudienceValidation = true, + // Presenter validation is disabled for the token endpoint as this endpoint + // implements a specialized event handler that uses more complex rules. + DisablePresenterValidation = context.EndpointType is OpenIddictServerEndpointType.Token, Token = context.AuthorizationCode, ValidTokenTypes = { TokenTypeIdentifiers.Private.AuthorizationCode } }; + if (!string.IsNullOrEmpty(context.ClientId)) + { + notification.ValidPresenters.Add(context.ClientId); + } + await _dispatcher.DispatchAsync(notification); if (notification.IsRequestHandled) @@ -1587,10 +1605,19 @@ public static partial class OpenIddictServerHandlers var notification = new ValidateTokenContext(context.Transaction) { + DisableAudienceValidation = true, + // Presenter validation is disabled for the token endpoint as this endpoint + // implements a specialized event handler that uses more complex rules. + DisablePresenterValidation = context.EndpointType is OpenIddictServerEndpointType.Token, Token = context.DeviceCode, ValidTokenTypes = { TokenTypeIdentifiers.Private.DeviceCode } }; + if (!string.IsNullOrEmpty(context.ClientId)) + { + notification.ValidPresenters.Add(context.ClientId); + } + await _dispatcher.DispatchAsync(notification); if (notification.IsRequestHandled) @@ -1659,6 +1686,12 @@ public static partial class OpenIddictServerHandlers var notification = new ValidateTokenContext(context.Transaction) { + // Audience and presenter validation is disabled for the introspection and revocation endpoints + // as these endpoints implement specialized event handlers that use more complex rules. + DisableAudienceValidation = context.EndpointType is OpenIddictServerEndpointType.Introspection or + OpenIddictServerEndpointType.Revocation, + DisablePresenterValidation = context.EndpointType is OpenIddictServerEndpointType.Introspection or + OpenIddictServerEndpointType.Revocation, Token = context.GenericToken, TokenTypeHint = context.GenericTokenTypeHint, @@ -1749,14 +1782,27 @@ public static partial class OpenIddictServerHandlers var notification = new ValidateTokenContext(context.Transaction) { - // Don't validate the lifetime of id_tokens used as id_token_hints. + // Audience and presenter validation is disabled for the authorization and end session endpoints + // as these endpoints implement specialized event handlers that use more complex rules. + DisableAudienceValidation = context.EndpointType is OpenIddictServerEndpointType.Authorization or + OpenIddictServerEndpointType.EndSession or + OpenIddictServerEndpointType.PushedAuthorization, + // Don't validate the lifetime of identity token used as hints. DisableLifetimeValidation = context.EndpointType is OpenIddictServerEndpointType.Authorization or OpenIddictServerEndpointType.EndSession or OpenIddictServerEndpointType.PushedAuthorization, + DisablePresenterValidation = context.EndpointType is OpenIddictServerEndpointType.Authorization or + OpenIddictServerEndpointType.EndSession or + OpenIddictServerEndpointType.PushedAuthorization, Token = context.IdentityToken, ValidTokenTypes = { TokenTypeIdentifiers.IdentityToken } }; + if (!string.IsNullOrEmpty(context.ClientId)) + { + notification.ValidPresenters.Add(context.ClientId); + } + await _dispatcher.DispatchAsync(notification); if (notification.IsRequestHandled) @@ -1825,10 +1871,19 @@ public static partial class OpenIddictServerHandlers var notification = new ValidateTokenContext(context.Transaction) { + DisableAudienceValidation = true, + // Presenter validation is disabled for the token endpoint as this endpoint + // implements a specialized event handler that uses more complex rules. + DisablePresenterValidation = context.EndpointType is OpenIddictServerEndpointType.Token, Token = context.RefreshToken, ValidTokenTypes = { TokenTypeIdentifiers.RefreshToken } }; + if (!string.IsNullOrEmpty(context.ClientId)) + { + notification.ValidPresenters.Add(context.ClientId); + } + await _dispatcher.DispatchAsync(notification); if (notification.IsRequestHandled) @@ -1897,10 +1952,17 @@ public static partial class OpenIddictServerHandlers var notification = new ValidateTokenContext(context.Transaction) { + DisableAudienceValidation = true, + DisablePresenterValidation = context.EndpointType is OpenIddictServerEndpointType.EndUserVerification, Token = context.UserCode, ValidTokenTypes = { TokenTypeIdentifiers.Private.UserCode } }; + if (!string.IsNullOrEmpty(context.ClientId)) + { + notification.ValidPresenters.Add(context.ClientId); + } + // Note: restrict the allowed characters to the user code charset set in the options. notification.AllowedCharset.UnionWith(context.Options.UserCodeCharset); @@ -2304,7 +2366,7 @@ public static partial class OpenIddictServerHandlers throw new ArgumentNullException(nameof(context)); } - if (context.Parameters.Count > 0) + if (context.Parameters.Count is > 0) { foreach (var parameter in context.Parameters) { @@ -2801,14 +2863,13 @@ public static partial class OpenIddictServerHandlers Debug.Assert(context.Principal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); // When a "resources" property cannot be found in the ticket, infer it from the "audiences" property. - if (context.Principal.HasClaim(Claims.Private.Audience) && - !context.Principal.HasClaim(Claims.Private.Resource)) + if (context.Principal.HasClaim(Claims.Private.Audience) && !context.Principal.HasClaim(Claims.Private.Resource)) { context.Principal.SetResources(context.Principal.GetAudiences()); } // Reset the audiences collection, as it's later set, based on the token type. - context.Principal.SetAudiences(ImmutableArray.Empty); + context.Principal.SetAudiences([]); return default; } @@ -4879,7 +4940,7 @@ public static partial class OpenIddictServerHandlers throw new ArgumentNullException(nameof(context)); } - if (context.Parameters.Count > 0) + if (context.Parameters.Count is > 0) { foreach (var parameter in context.Parameters) { @@ -5014,7 +5075,7 @@ public static partial class OpenIddictServerHandlers throw new ArgumentNullException(nameof(context)); } - if (context.Parameters.Count > 0) + if (context.Parameters.Count is > 0) { foreach (var parameter in context.Parameters) { @@ -5081,7 +5142,7 @@ public static partial class OpenIddictServerHandlers throw new ArgumentNullException(nameof(context)); } - if (context.Parameters.Count > 0) + if (context.Parameters.Count is > 0) { foreach (var parameter in context.Parameters) { diff --git a/src/OpenIddict.Validation/OpenIddictValidationEvents.Protection.cs b/src/OpenIddict.Validation/OpenIddictValidationEvents.Protection.cs index c07fe70f..9fa9b72f 100644 --- a/src/OpenIddict.Validation/OpenIddictValidationEvents.Protection.cs +++ b/src/OpenIddict.Validation/OpenIddictValidationEvents.Protection.cs @@ -123,6 +123,21 @@ public static partial class OpenIddictValidationEvents set => Transaction.Request = value; } + /// + /// Gets or sets a boolean indicating whether audience validation is disabled. + /// + public bool DisableAudienceValidation { get; set; } + + /// + /// Gets or sets a boolean indicating whether lifetime validation is disabled. + /// + public bool DisableLifetimeValidation { get; set; } + + /// + /// Gets or sets a boolean indicating whether presenter validation is disabled. + /// + public bool DisablePresenterValidation { get; set; } + /// /// Gets or sets the security token handler used to validate the token. /// @@ -173,6 +188,18 @@ public static partial class OpenIddictValidationEvents /// public HashSet AllowedCharset { get; } = new(StringComparer.Ordinal); + /// + /// Gets the audiences that are considered valid. If no value + /// is explicitly specified, all audiences are considered valid. + /// + public HashSet ValidAudiences { get; } = new(StringComparer.Ordinal); + + /// + /// Gets the presenters that are considered valid. If no value + /// is explicitly specified, all presenters are considered valid. + /// + public HashSet ValidPresenters { get; } = new(StringComparer.Ordinal); + /// /// Gets the token types that are considered valid. If no value is /// explicitly specified, all supported tokens are considered valid. diff --git a/src/OpenIddict.Validation/OpenIddictValidationExtensions.cs b/src/OpenIddict.Validation/OpenIddictValidationExtensions.cs index e15247e1..a299fcec 100644 --- a/src/OpenIddict.Validation/OpenIddictValidationExtensions.cs +++ b/src/OpenIddict.Validation/OpenIddictValidationExtensions.cs @@ -49,8 +49,11 @@ public static class OpenIddictValidationExtensions builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); // Note: TryAddEnumerable() is used here to ensure the initializer is registered only once. builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton< diff --git a/src/OpenIddict.Validation/OpenIddictValidationHandlerFilters.cs b/src/OpenIddict.Validation/OpenIddictValidationHandlerFilters.cs index f98b3f9e..57005ab7 100644 --- a/src/OpenIddict.Validation/OpenIddictValidationHandlerFilters.cs +++ b/src/OpenIddict.Validation/OpenIddictValidationHandlerFilters.cs @@ -147,6 +147,23 @@ public static class OpenIddictValidationHandlerFilters } } + /// + /// Represents a filter that excludes the associated handlers if token audience validation was disabled. + /// + public sealed class RequireTokenAudienceValidationEnabled : IOpenIddictValidationHandlerFilter + { + /// + public ValueTask IsActiveAsync(ValidateTokenContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + return new(!context.DisableAudienceValidation); + } + } + /// /// Represents a filter that excludes the associated handlers if no token identifier is resolved from the token. /// @@ -164,6 +181,23 @@ public static class OpenIddictValidationHandlerFilters } } + /// + /// Represents a filter that excludes the associated handlers if token lifetime validation was disabled. + /// + public sealed class RequireTokenLifetimeValidationEnabled : IOpenIddictValidationHandlerFilter + { + /// + public ValueTask IsActiveAsync(ValidateTokenContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + return new(!context.DisableLifetimeValidation); + } + } + /// /// Represents a filter that excludes the associated handlers if token validation was not enabled. /// @@ -180,4 +214,21 @@ public static class OpenIddictValidationHandlerFilters return new(context.Options.EnableTokenEntryValidation); } } + + /// + /// Represents a filter that excludes the associated handlers if token presenter validation was disabled. + /// + public sealed class RequireTokenPresenterValidationEnabled : IOpenIddictValidationHandlerFilter + { + /// + public ValueTask IsActiveAsync(ValidateTokenContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + return new(!context.DisablePresenterValidation); + } + } } diff --git a/src/OpenIddict.Validation/OpenIddictValidationHandlers.Protection.cs b/src/OpenIddict.Validation/OpenIddictValidationHandlers.Protection.cs index 95956747..91ba17c0 100644 --- a/src/OpenIddict.Validation/OpenIddictValidationHandlers.Protection.cs +++ b/src/OpenIddict.Validation/OpenIddictValidationHandlers.Protection.cs @@ -32,7 +32,8 @@ public static partial class OpenIddictValidationHandlers RestoreTokenEntryProperties.Descriptor, ValidatePrincipal.Descriptor, ValidateExpirationDate.Descriptor, - ValidateAudience.Descriptor, + ValidatePresenters.Descriptor, + ValidateAudiences.Descriptor, ValidateTokenEntry.Descriptor, ValidateAuthorizationEntry.Descriptor, @@ -595,7 +596,7 @@ public static partial class OpenIddictValidationHandlers } /// - /// Contains the logic responsible for rejecting authentication demands for which no valid principal was resolved. + /// Contains the logic responsible for rejecting tokens for which no valid principal could be resolved. /// public sealed class ValidatePrincipal : IOpenIddictValidationHandler { @@ -637,7 +638,7 @@ public static partial class OpenIddictValidationHandlers throw new InvalidOperationException(SR.GetResourceString(SR.ID0004)); } - if (context.ValidTokenTypes.Count > 0 && !context.ValidTokenTypes.Contains(type)) + if (context.ValidTokenTypes.Count is > 0 && !context.ValidTokenTypes.Contains(type)) { throw new InvalidOperationException(SR.FormatID0005(type, string.Join(", ", context.ValidTokenTypes))); } @@ -647,7 +648,7 @@ public static partial class OpenIddictValidationHandlers } /// - /// Contains the logic responsible for rejecting authentication demands containing expired access tokens. + /// Contains the logic responsible for rejecting expired tokens. /// public sealed class ValidateExpirationDate : IOpenIddictValidationHandler { @@ -656,6 +657,7 @@ public static partial class OpenIddictValidationHandlers /// public static OpenIddictValidationHandlerDescriptor Descriptor { get; } = OpenIddictValidationHandlerDescriptor.CreateBuilder() + .AddFilter() .UseSingletonHandler() .SetOrder(ValidatePrincipal.Descriptor.Order + 1_000) .SetType(OpenIddictValidationHandlerType.BuiltIn) @@ -689,17 +691,17 @@ public static partial class OpenIddictValidationHandlers } /// - /// Contains the logic responsible for rejecting authentication demands containing - /// access tokens that were issued to be used by another audience/resource server. + /// Contains the logic responsible for rejecting tokens that can't be used by the caller. /// - public sealed class ValidateAudience : IOpenIddictValidationHandler + public sealed class ValidatePresenters : IOpenIddictValidationHandler { /// /// Gets the default descriptor definition assigned to this handler. /// public static OpenIddictValidationHandlerDescriptor Descriptor { get; } = OpenIddictValidationHandlerDescriptor.CreateBuilder() - .UseSingletonHandler() + .AddFilter() + .UseSingletonHandler() .SetOrder(ValidateExpirationDate.Descriptor.Order + 1_000) .SetType(OpenIddictValidationHandlerType.BuiltIn) .Build(); @@ -714,18 +716,80 @@ public static partial class OpenIddictValidationHandlers Debug.Assert(context.Principal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); - // If no explicit audience has been configured, - // skip the default audience validation. - if (context.Options.Audiences.Count is 0) + // If no specific value is expected, skip the default presenter validation. + if (context.ValidPresenters.Count is 0) { return default; } - // If the access token doesn't have any audience attached, return an error. + // If the token doesn't have any presenter attached, return an error. + var presenters = context.Principal.GetPresenters(); + if (presenters.IsDefaultOrEmpty) + { + context.Logger.LogInformation(6264, SR.GetResourceString(SR.ID6264)); + + context.Reject( + error: Errors.InvalidToken, + description: SR.GetResourceString(SR.ID2184), + uri: SR.FormatID8000(SR.ID2184)); + + return default; + } + + // If the token doesn't include any registered presenter, return an error. + if (!OpenIddictHelpers.IncludesAnyFromSet(presenters, context.ValidPresenters)) + { + context.Logger.LogInformation(6265, SR.GetResourceString(SR.ID6265)); + + context.Reject( + error: Errors.InvalidToken, + description: SR.GetResourceString(SR.ID2185), + uri: SR.FormatID8000(SR.ID2185)); + + return default; + } + + return default; + } + } + + /// + /// Contains the logic responsible for rejecting tokens issued for different recipients. + /// + public sealed class ValidateAudiences : IOpenIddictValidationHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictValidationHandlerDescriptor Descriptor { get; } + = OpenIddictValidationHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(ValidatePresenters.Descriptor.Order + 1_000) + .SetType(OpenIddictValidationHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ValidateTokenContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(context.Principal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); + + // If no specific value is expected, skip the default audience validation. + if (context.ValidAudiences.Count is 0) + { + return default; + } + + // If the token doesn't have any audience attached, return an error. var audiences = context.Principal.GetAudiences(); if (audiences.IsDefaultOrEmpty) { - context.Logger.LogInformation(6157, SR.GetResourceString(SR.ID6157)); + context.Logger.LogInformation(6266, SR.GetResourceString(SR.ID6266)); context.Reject( error: Errors.InvalidToken, @@ -735,10 +799,10 @@ public static partial class OpenIddictValidationHandlers return default; } - // If the access token doesn't include any registered audience, return an error. - if (!audiences.Intersect(context.Options.Audiences, StringComparer.Ordinal).Any()) + // If the token doesn't include any registered audience, return an error. + if (!OpenIddictHelpers.IncludesAnyFromSet(audiences, context.ValidAudiences)) { - context.Logger.LogInformation(6158, SR.GetResourceString(SR.ID6158)); + context.Logger.LogInformation(6267, SR.GetResourceString(SR.ID6267)); context.Reject( error: Errors.InvalidToken, @@ -753,9 +817,8 @@ public static partial class OpenIddictValidationHandlers } /// - /// Contains the logic responsible for authentication demands a token whose + /// Contains the logic responsible for rejecting tokens whose /// associated token entry is no longer valid (e.g was revoked). - /// Note: this handler is not used when the degraded mode is enabled. /// public sealed class ValidateTokenEntry : IOpenIddictValidationHandler { @@ -774,7 +837,7 @@ public static partial class OpenIddictValidationHandlers .AddFilter() .AddFilter() .UseScopedHandler() - .SetOrder(ValidateAudience.Descriptor.Order + 1_000) + .SetOrder(ValidateAudiences.Descriptor.Order + 1_000) .SetType(OpenIddictValidationHandlerType.BuiltIn) .Build(); @@ -807,9 +870,8 @@ public static partial class OpenIddictValidationHandlers } /// - /// Contains the logic responsible for authentication demands a token whose + /// Contains the logic responsible for rejecting tokens whose /// associated authorization entry is no longer valid (e.g was revoked). - /// Note: this handler is not used when the degraded mode is enabled. /// public sealed class ValidateAuthorizationEntry : IOpenIddictValidationHandler { diff --git a/src/OpenIddict.Validation/OpenIddictValidationHandlers.cs b/src/OpenIddict.Validation/OpenIddictValidationHandlers.cs index c4786bb5..37a5b8d1 100644 --- a/src/OpenIddict.Validation/OpenIddictValidationHandlers.cs +++ b/src/OpenIddict.Validation/OpenIddictValidationHandlers.cs @@ -742,7 +742,7 @@ public static partial class OpenIddictValidationHandlers } // If the access token doesn't include any registered audience, return an error. - if (!audiences.Intersect(context.Options.Audiences, StringComparer.Ordinal).Any()) + if (!OpenIddictHelpers.IncludesAnyFromSet(audiences, context.Options.Audiences)) { context.Logger.LogInformation(6158, SR.GetResourceString(SR.ID6158)); @@ -798,6 +798,10 @@ public static partial class OpenIddictValidationHandlers ValidTokenTypes = { TokenTypeIdentifiers.AccessToken } }; + // Note: by default, access tokens are not constrainted to specific presenters but must contain + // at least one audience matching one of the values configured in the options, if applicable. + notification.ValidAudiences.UnionWith(context.Options.Audiences); + await _dispatcher.DispatchAsync(notification); if (notification.IsRequestHandled) @@ -895,7 +899,7 @@ public static partial class OpenIddictValidationHandlers throw new ArgumentNullException(nameof(context)); } - if (context.Parameters.Count > 0) + if (context.Parameters.Count is > 0) { foreach (var parameter in context.Parameters) { @@ -962,7 +966,7 @@ public static partial class OpenIddictValidationHandlers throw new ArgumentNullException(nameof(context)); } - if (context.Parameters.Count > 0) + if (context.Parameters.Count is > 0) { foreach (var parameter in context.Parameters) { diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Userinfo.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Userinfo.cs index 357802bb..0b4d3432 100644 --- a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Userinfo.cs +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Userinfo.cs @@ -415,7 +415,7 @@ public abstract partial class OpenIddictServerIntegrationTests context.Principal = new ClaimsPrincipal(identity) .SetTokenType(TokenTypeIdentifiers.AccessToken) .SetPresenters("Fabrikam") - .SetScopes(ImmutableArray.Empty); + .SetScopes([]); return default; }); diff --git a/test/OpenIddict.Validation.IntegrationTests/OpenIddictValidationIntegrationTests.cs b/test/OpenIddict.Validation.IntegrationTests/OpenIddictValidationIntegrationTests.cs index e22a38ee..d9c3b93b 100644 --- a/test/OpenIddict.Validation.IntegrationTests/OpenIddictValidationIntegrationTests.cs +++ b/test/OpenIddict.Validation.IntegrationTests/OpenIddictValidationIntegrationTests.cs @@ -177,6 +177,125 @@ public abstract partial class OpenIddictValidationIntegrationTests Assert.Equal("Bob le Magnifique", (string?) response[Claims.Subject]); } + [Fact] + public async Task ProcessAuthentication_RejectsDemandWhenAccessTokenAudienceIsMissing() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.AddAudiences("Fabrikam"); + + options.AddEventHandler(builder => + { + builder.UseInlineHandler(context => + { + Assert.Equal("access_token", context.Token); + Assert.Equal([TokenTypeIdentifiers.AccessToken], context.ValidTokenTypes); + + context.Principal = new ClaimsPrincipal(new ClaimsIdentity("Bearer")) + .SetAudiences([]) + .SetTokenType(TokenTypeIdentifiers.AccessToken) + .SetClaim(Claims.Subject, "Bob le Magnifique"); + + return default; + }); + + builder.SetOrder(ValidateIdentityModelToken.Descriptor.Order - 500); + }); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.GetAsync("/authenticate", new OpenIddictRequest + { + AccessToken = "access_token" + }); + + // Assert + Assert.Equal(Errors.InvalidToken, response.Error); + Assert.Equal(SR.GetResourceString(SR.ID2093), response.ErrorDescription); + } + + [Fact] + public async Task ProcessAuthentication_RejectsDemandWhenAccessTokenAudienceIsInvalid() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.AddAudiences("Fabrikam"); + + options.AddEventHandler(builder => + { + builder.UseInlineHandler(context => + { + Assert.Equal("access_token", context.Token); + Assert.Equal([TokenTypeIdentifiers.AccessToken], context.ValidTokenTypes); + + context.Principal = new ClaimsPrincipal(new ClaimsIdentity("Bearer")) + .SetAudiences("Contoso") + .SetTokenType(TokenTypeIdentifiers.AccessToken) + .SetClaim(Claims.Subject, "Bob le Magnifique"); + + return default; + }); + + builder.SetOrder(ValidateIdentityModelToken.Descriptor.Order - 500); + }); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.GetAsync("/authenticate", new OpenIddictRequest + { + AccessToken = "access_token" + }); + + // Assert + Assert.Equal(Errors.InvalidToken, response.Error); + Assert.Equal(SR.GetResourceString(SR.ID2094), response.ErrorDescription); + } + + [Fact] + public async Task ProcessAuthentication_ReturnsExpectedIdentityWhenAccessTokenAudienceIsValid() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.AddAudiences("Fabrikam"); + + options.AddEventHandler(builder => + { + builder.UseInlineHandler(context => + { + Assert.Equal("access_token", context.Token); + Assert.Equal([TokenTypeIdentifiers.AccessToken], context.ValidTokenTypes); + + context.Principal = new ClaimsPrincipal(new ClaimsIdentity("Bearer")) + .SetAudiences("Fabrikam") + .SetTokenType(TokenTypeIdentifiers.AccessToken) + .SetClaim(Claims.Subject, "Bob le Magnifique"); + + return default; + }); + + builder.SetOrder(ValidateIdentityModelToken.Descriptor.Order - 500); + }); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.GetAsync("/authenticate", new OpenIddictRequest + { + AccessToken = "access_token" + }); + + // Assert + Assert.Equal("Bob le Magnifique", (string?) response[Claims.Subject]); + } + [Fact] public async Task ProcessChallenge_ReturnsDefaultErrorWhenNoneIsSpecified() {