diff --git a/gen/OpenIddict.Client.WebIntegration.Generators/OpenIddictClientWebIntegrationGenerator.cs b/gen/OpenIddict.Client.WebIntegration.Generators/OpenIddictClientWebIntegrationGenerator.cs index ec85e49c..b90d8b63 100644 --- a/gen/OpenIddict.Client.WebIntegration.Generators/OpenIddictClientWebIntegrationGenerator.cs +++ b/gen/OpenIddict.Client.WebIntegration.Generators/OpenIddictClientWebIntegrationGenerator.cs @@ -1,6 +1,4 @@ -using System.Globalization; -using System.Text; -using System.Text.RegularExpressions; +using System.Text; using System.Xml.Linq; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; diff --git a/src/OpenIddict.Abstractions/OpenIddictResources.resx b/src/OpenIddict.Abstractions/OpenIddictResources.resx index cb4780ed..f92f07c4 100644 --- a/src/OpenIddict.Abstractions/OpenIddictResources.resx +++ b/src/OpenIddict.Abstractions/OpenIddictResources.resx @@ -1301,6 +1301,18 @@ Alternatively, you can disable the token storage feature by calling 'services.Ad The '{0}' node cannot be extracted from the response. + + The username cannot be null or empty. + + + The password cannot be null or empty. + + + A username must be specified when using the resource owner password credentials grant. + + + A password must be specified when using the resource owner password credentials grant. + The security token is missing. @@ -1757,6 +1769,12 @@ Alternatively, you can disable the token storage feature by calling 'services.Ad The issuer should be a valid absolute URL at this point. + + The username shouldn't be null or empty at this point. + + + The password shouldn't be null or empty at this point. + An error occurred while validating the token '{Token}'. diff --git a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.cs b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.cs index 8062e730..8ffc9fb1 100644 --- a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.cs +++ b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.cs @@ -133,6 +133,7 @@ public static partial class OpenIddictClientWebIntegrationHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() .UseSingletonHandler() // Note: this handler MUST be invoked after the scopes have been attached to the // context to support overriding the response mode based on the requested scopes. @@ -172,6 +173,7 @@ public static partial class OpenIddictClientWebIntegrationHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() .UseSingletonHandler() .SetOrder(AttachChallengeParameters.Descriptor.Order + 500) .SetType(OpenIddictClientHandlerType.BuiltIn) @@ -189,9 +191,7 @@ public static partial class OpenIddictClientWebIntegrationHandlers { // The following providers are known to use comma-separated scopes instead of // the standard format (that requires using a space as the scope separator): - Providers.Reddit - when context.GrantType is GrantTypes.AuthorizationCode or GrantTypes.Implicit - => string.Join(",", context.Scopes), + Providers.Reddit => string.Join(",", context.Scopes), _ => context.Request.Scope }; diff --git a/src/OpenIddict.Client/OpenIddictClientConfiguration.cs b/src/OpenIddict.Client/OpenIddictClientConfiguration.cs index ae47c5b8..49570464 100644 --- a/src/OpenIddict.Client/OpenIddictClientConfiguration.cs +++ b/src/OpenIddict.Client/OpenIddictClientConfiguration.cs @@ -41,6 +41,16 @@ public class OpenIddictClientConfiguration : IPostConfigureOptions public string? RefreshToken { get; set; } + /// + /// Gets or sets the username to send to the server, if applicable. + /// + public string? Username { get; set; } + + /// + /// Gets or sets the password to send to the server, if applicable. + /// + public string? Password { get; set; } + /// /// Gets or sets the frontchannel state token to validate, if applicable. /// diff --git a/src/OpenIddict.Client/OpenIddictClientExtensions.cs b/src/OpenIddict.Client/OpenIddictClientExtensions.cs index 9531ea5a..5425427a 100644 --- a/src/OpenIddict.Client/OpenIddictClientExtensions.cs +++ b/src/OpenIddict.Client/OpenIddictClientExtensions.cs @@ -36,7 +36,6 @@ public static class OpenIddictClientExtensions builder.Services.TryAddSingleton(); // Register the built-in filters used by the default OpenIddict client event handlers. - builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); @@ -45,6 +44,7 @@ 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(); diff --git a/src/OpenIddict.Client/OpenIddictClientHandlerFilters.cs b/src/OpenIddict.Client/OpenIddictClientHandlerFilters.cs index 9c3deb03..870ebbb4 100644 --- a/src/OpenIddict.Client/OpenIddictClientHandlerFilters.cs +++ b/src/OpenIddict.Client/OpenIddictClientHandlerFilters.cs @@ -11,23 +11,6 @@ namespace OpenIddict.Client; [EditorBrowsable(EditorBrowsableState.Advanced)] public static class OpenIddictClientHandlerFilters { - /// - /// Represents a filter that excludes the associated handlers if the challenge - /// doesn't correspond to an authorization code or implicit grant operation. - /// - public class RequireAuthorizationCodeOrImplicitGrantType : IOpenIddictClientHandlerFilter - { - public ValueTask IsActiveAsync(ProcessChallengeContext context) - { - if (context is null) - { - throw new ArgumentNullException(nameof(context)); - } - - return new(context.GrantType is GrantTypes.AuthorizationCode or GrantTypes.Implicit); - } - } - /// /// Represents a filter that excludes the associated handlers if no authorization code is validated. /// @@ -156,6 +139,23 @@ public static class OpenIddictClientHandlerFilters } } + /// + /// Represents a filter that excludes the associated handlers if the challenge + /// doesn't correspond to an authorization code or implicit grant operation. + /// + public class RequireInteractiveGrantType : IOpenIddictClientHandlerFilter + { + public ValueTask IsActiveAsync(ProcessChallengeContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + return new(context.GrantType is GrantTypes.AuthorizationCode or GrantTypes.Implicit); + } + } + /// /// Represents a filter that excludes the associated handlers if the selected token format is not JSON Web Token. /// diff --git a/src/OpenIddict.Client/OpenIddictClientHandlers.Authentication.cs b/src/OpenIddict.Client/OpenIddictClientHandlers.Authentication.cs index 43c0f48b..cc556195 100644 --- a/src/OpenIddict.Client/OpenIddictClientHandlers.Authentication.cs +++ b/src/OpenIddict.Client/OpenIddictClientHandlers.Authentication.cs @@ -59,7 +59,7 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() + .AddFilter() .UseScopedHandler() .SetOrder(int.MaxValue - 100_000) .Build(); @@ -104,7 +104,7 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() + .AddFilter() .UseScopedHandler() .SetOrder(PrepareAuthorizationRequest.Descriptor.Order + 1_000) .Build(); diff --git a/src/OpenIddict.Client/OpenIddictClientHandlers.cs b/src/OpenIddict.Client/OpenIddictClientHandlers.cs index ae9b6a0f..fab977d0 100644 --- a/src/OpenIddict.Client/OpenIddictClientHandlers.cs +++ b/src/OpenIddict.Client/OpenIddictClientHandlers.cs @@ -10,8 +10,6 @@ using System.Diagnostics; using System.Security.Claims; using System.Security.Cryptography; using System.Text; -using System.Text.Json; -using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; #if !SUPPORTS_TIME_CONSTANT_COMPARISONS @@ -158,12 +156,27 @@ public static partial class OpenIddictClientHandlers throw new InvalidOperationException(SR.GetResourceString(SR.ID0309)); } - if (context.GrantType is not GrantTypes.RefreshToken) + if (context.GrantType is not (GrantTypes.AuthorizationCode or GrantTypes.Implicit or + GrantTypes.Password or GrantTypes.RefreshToken)) { throw new InvalidOperationException(SR.FormatID0310(context.GrantType)); } - if (string.IsNullOrEmpty(context.RefreshToken)) + if (context.GrantType is GrantTypes.Password) + { + if (string.IsNullOrEmpty(context.Username)) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0337)); + } + + if (string.IsNullOrEmpty(context.Password)) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0338)); + } + } + + if (context.GrantType is GrantTypes.RefreshToken && + string.IsNullOrEmpty(context.RefreshToken)) { throw new InvalidOperationException(SR.GetResourceString(SR.ID0311)); } @@ -1045,7 +1058,7 @@ public static partial class OpenIddictClientHandlers // In any case, the client identifier of the application MUST be included in the audiences. // See https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation for more information. var audiences = context.FrontchannelIdentityTokenPrincipal.GetClaims(Claims.Audience); - if (!audiences.Contains(context.Registration.ClientId!)) + if (!string.IsNullOrEmpty(context.Registration.ClientId) && !audiences.Contains(context.Registration.ClientId)) { context.Reject( error: Errors.InvalidRequest, @@ -1087,7 +1100,7 @@ public static partial class OpenIddictClientHandlers // Note: the "azp" claim is optional, but if it's present, it MUST match the client identifier of the application. // See https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation for more information. var presenter = context.FrontchannelIdentityTokenPrincipal.GetClaim(Claims.AuthorizedParty); - if (!string.IsNullOrEmpty(presenter) && + if (!string.IsNullOrEmpty(presenter) && !string.IsNullOrEmpty(context.Registration.ClientId) && !string.Equals(presenter, context.Registration.ClientId, StringComparison.Ordinal)) { context.Reject( @@ -1504,8 +1517,8 @@ public static partial class OpenIddictClientHandlers GrantTypes.AuthorizationCode or GrantTypes.Implicit when HasResponseType(ResponseTypes.Code) => true, - // For refresh token requests, always send a token request. - GrantTypes.RefreshToken => true, + // For resource owner password credentials and refresh token requests, always send a token request. + GrantTypes.Password or GrantTypes.RefreshToken => true, _ => false }; @@ -1570,6 +1583,16 @@ public static partial class OpenIddictClientHandlers context.TokenRequest.RedirectUri = context.StateTokenPrincipal.GetClaim(Claims.Private.RedirectUri); } + // If the token request uses a resource owner password credentials grant, attach the credentials to the request. + else if (context.TokenRequest.GrantType is GrantTypes.Password) + { + Debug.Assert(!string.IsNullOrEmpty(context.Username), SR.GetResourceString(SR.ID4014)); + Debug.Assert(!string.IsNullOrEmpty(context.Password), SR.GetResourceString(SR.ID4015)); + + context.TokenRequest.Username = context.Username; + context.TokenRequest.Password = context.Password; + } + // If the token request uses a refresh token grant, attach the refresh token to the request. else if (context.TokenRequest.GrantType is GrantTypes.RefreshToken) { @@ -1932,8 +1955,9 @@ public static partial class OpenIddictClientHandlers GrantTypes.AuthorizationCode or GrantTypes.Implicit when HasResponseType(ResponseTypes.Code) => (true, true, false), - // An access token is always returned as part of refresh token responses. - GrantTypes.RefreshToken => (true, true, false), + // An access token is always returned as part of resource + // owner password credentials and refresh token responses. + GrantTypes.Password or GrantTypes.RefreshToken => (true, true, false), _ => (false, false, false) }; @@ -1949,6 +1973,14 @@ public static partial class OpenIddictClientHandlers GrantTypes.AuthorizationCode or GrantTypes.Implicit when HasResponseType(ResponseTypes.Code) && context.StateTokenPrincipal!.HasScope(Scopes.OpenId) => (true, true, true), + // The resource owner password credentials grant doesn't have an equivalent in + // OpenID Connect so an identity token is typically never returned when using it. + // However, certain server implementations - like OpenIddict - allow returning it + // as a non-standard artifact. As such, the identity token is not considered required + // but will always be validated using the same routine (except nonce validation) + // if it is present in the token response. + GrantTypes.Password => (true, false, true), + // An identity token may or may not be returned as part of refresh token responses // depending on the policy adopted by the remote authorization server. As such, // the identity token is not considered required but will always be validated using @@ -1973,10 +2005,11 @@ public static partial class OpenIddictClientHandlers GrantTypes.AuthorizationCode or GrantTypes.Implicit when HasResponseType(ResponseTypes.Code) => (true, false, false), - // A refresh token may or may not be returned as part of refresh token responses - // depending on the policy adopted by the remote authorization server. As such, - // a refresh token is never considered required for refresh token responses. - GrantTypes.RefreshToken => (true, false, false), + // A refresh token may or may not be returned as part of resource owner password + // credentials and refresh token responses depending on the policy adopted by the + // remote authorization server. As such, a refresh token is never considered + // required for refresh token responses. + GrantTypes.Password or GrantTypes.RefreshToken => (true, false, false), _ => (false, false, false) }; @@ -2310,7 +2343,7 @@ public static partial class OpenIddictClientHandlers // In any case, the client identifier of the application MUST be included in the audiences. // See https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation for more information. var audiences = context.BackchannelIdentityTokenPrincipal.GetClaims(Claims.Audience); - if (!audiences.Contains(context.Registration.ClientId!)) + if (!string.IsNullOrEmpty(context.Registration.ClientId) && !audiences.Contains(context.Registration.ClientId)) { context.Reject( error: Errors.InvalidRequest, @@ -2352,7 +2385,7 @@ public static partial class OpenIddictClientHandlers // Note: the "azp" claim is optional, but if it's present, it MUST match the client identifier of the application. // See https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation for more information. var presenter = context.BackchannelIdentityTokenPrincipal.GetClaim(Claims.AuthorizedParty); - if (!string.IsNullOrEmpty(presenter) && + if (!string.IsNullOrEmpty(presenter) && !string.IsNullOrEmpty(context.Registration.ClientId) && !string.Equals(presenter, context.Registration.ClientId, StringComparison.Ordinal)) { context.Reject( @@ -2732,7 +2765,8 @@ public static partial class OpenIddictClientHandlers // endpoint when a frontchannel or backchannel access token is available. // // Note: the userinfo endpoint is an optional endpoint and may not be supported. - GrantTypes.AuthorizationCode or GrantTypes.Implicit or GrantTypes.RefreshToken + GrantTypes.AuthorizationCode or GrantTypes.Implicit or + GrantTypes.Password or GrantTypes.RefreshToken when context.UserinfoEndpoint is not null && (!string.IsNullOrEmpty(context.BackchannelAccessToken) || !string.IsNullOrEmpty(context.FrontchannelAccessToken)) => true, @@ -3167,6 +3201,7 @@ public static partial class OpenIddictClientHandlers public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() .AddFilter() + .AddFilter() .UseScopedHandler() .SetOrder(ValidateUserinfoTokenSubject.Descriptor.Order + 1_000) .SetType(OpenIddictClientHandlerType.BuiltIn) @@ -3423,7 +3458,7 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() + .AddFilter() .UseSingletonHandler() .SetOrder(EvaluateGeneratedChallengeTokens.Descriptor.Order + 1_000) .Build(); @@ -3614,7 +3649,7 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() + .AddFilter() .UseSingletonHandler() .SetOrder(AttachResponseType.Descriptor.Order + 1_000) .Build(); @@ -3719,7 +3754,7 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() + .AddFilter() .UseSingletonHandler() .SetOrder(AttachResponseMode.Descriptor.Order + 1_000) .Build(); @@ -3748,7 +3783,7 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() + .AddFilter() .UseSingletonHandler() .SetOrder(AttachClientId.Descriptor.Order + 1_000) .Build(); @@ -3781,7 +3816,7 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() + .AddFilter() .UseSingletonHandler() .SetOrder(AttachRedirectUri.Descriptor.Order + 1_000) .Build(); @@ -3826,7 +3861,7 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() + .AddFilter() .UseSingletonHandler() .SetOrder(AttachScopes.Descriptor.Order + 1_000) .Build(); @@ -3864,7 +3899,7 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() + .AddFilter() .UseSingletonHandler() .SetOrder(AttachRequestForgeryProtection.Descriptor.Order + 1_000) .Build(); @@ -3907,7 +3942,7 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() + .AddFilter() .UseSingletonHandler() .SetOrder(AttachNonce.Descriptor.Order + 1_000) .Build(); @@ -4179,7 +4214,7 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() + .AddFilter() .UseSingletonHandler() .SetOrder(GenerateStateToken.Descriptor.Order + 1_000) .Build(); diff --git a/src/OpenIddict.Client/OpenIddictClientService.cs b/src/OpenIddict.Client/OpenIddictClientService.cs index fd3c0335..a9402ca7 100644 --- a/src/OpenIddict.Client/OpenIddictClientService.cs +++ b/src/OpenIddict.Client/OpenIddictClientService.cs @@ -332,23 +332,29 @@ public class OpenIddictClientService } /// - /// Refreshes the user tokens using the specified refresh token. + /// Authenticates using the resource owner password credentials grant and resolves the corresponding tokens. /// /// The client registration. - /// The refresh token to use. + /// The username to use. + /// The password to use. /// The that can be used to abort the operation. /// The response and a merged principal containing the claims extracted from the tokens and userinfo response. - public async ValueTask<(OpenIddictResponse Response, ClaimsPrincipal Principal)> RefreshTokensAsync( - OpenIddictClientRegistration registration, string token, CancellationToken cancellationToken = default) + public async ValueTask<(OpenIddictResponse Response, ClaimsPrincipal Principal)> AuthenticateWithPasswordAsync( + OpenIddictClientRegistration registration, string username, string password, CancellationToken cancellationToken = default) { if (registration is null) { throw new ArgumentNullException(nameof(registration)); } - if (string.IsNullOrEmpty(token)) + if (string.IsNullOrEmpty(username)) { - throw new ArgumentException(SR.GetResourceString(SR.ID0156), nameof(token)); + throw new ArgumentException(SR.GetResourceString(SR.ID0335), nameof(username)); + } + + if (string.IsNullOrEmpty(password)) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0336), nameof(password)); } var configuration = await registration.ConfigurationManager.GetConfigurationAsync(default) ?? @@ -378,10 +384,11 @@ public class OpenIddictClientService var context = new ProcessAuthenticationContext(transaction) { Configuration = configuration, - GrantType = GrantTypes.RefreshToken, + GrantType = GrantTypes.Password, Issuer = registration.Issuer, - RefreshToken = token, - Registration = registration + Password = password, + Registration = registration, + Username = username }; await dispatcher.DispatchAsync(context); @@ -414,44 +421,90 @@ public class OpenIddictClientService scope.Dispose(); } } + } + + /// + /// Authenticates using the refresh token grant and resolves the corresponding tokens. + /// + /// The client registration. + /// The refresh token to use. + /// The that can be used to abort the operation. + /// The response and a merged principal containing the claims extracted from the tokens and userinfo response. + public async ValueTask<(OpenIddictResponse Response, ClaimsPrincipal Principal)> AuthenticateWithRefreshTokenAsync( + OpenIddictClientRegistration registration, string token, CancellationToken cancellationToken = default) + { + if (registration is null) + { + throw new ArgumentNullException(nameof(registration)); + } + + if (string.IsNullOrEmpty(token)) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0156), nameof(token)); + } - static ClaimsPrincipal CreatePrincipal(params ClaimsPrincipal?[] principals) + var configuration = await registration.ConfigurationManager.GetConfigurationAsync(default) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0140)); + + if (configuration.TokenEndpoint is not { IsAbsoluteUri: true } || + !configuration.TokenEndpoint.IsWellFormedOriginalString()) { - // Note: the OpenIddict client handler can be used as a pure OAuth 2.0-only stack for - // delegation scenarios where the identity of the user is not needed. In this case, - // since no principal can be resolved from a token or a userinfo response to construct - // a user identity, a fake one containing an "unauthenticated" identity (i.e with its - // AuthenticationType property deliberately left to null) is used to allow ASP.NET Core - // to return a "successful" authentication result for these delegation-only scenarios. - if (!principals.Any(principal => principal?.Identity is ClaimsIdentity { IsAuthenticated: true })) + throw new InvalidOperationException(SR.FormatID0301(Metadata.TokenEndpoint)); + } + + cancellationToken.ThrowIfCancellationRequested(); + + // Note: this service is registered as a singleton service. As such, it cannot + // directly depend on scoped services like the validation provider. To work around + // this limitation, a scope is manually created for each method to this service. + var scope = _provider.CreateScope(); + + // Note: a try/finally block is deliberately used here to ensure the service scope + // can be disposed of asynchronously if it implements IAsyncDisposable. + try + { + var dispatcher = scope.ServiceProvider.GetRequiredService(); + var factory = scope.ServiceProvider.GetRequiredService(); + var transaction = await factory.CreateTransactionAsync(); + + var context = new ProcessAuthenticationContext(transaction) { - return new ClaimsPrincipal(new ClaimsIdentity()); - } + Configuration = configuration, + GrantType = GrantTypes.RefreshToken, + Issuer = registration.Issuer, + RefreshToken = token, + Registration = registration + }; - // Create a new composite identity containing the claims of all the principals. - var identity = new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType); + await dispatcher.DispatchAsync(context); - foreach (var principal in principals) + if (context.IsRejected) { - // Note: the principal may be null if no value was extracted from the corresponding token. - if (principal is null) - { - continue; - } + throw new OpenIddictExceptions.GenericException( + SR.FormatID0319(context.Error, context.ErrorDescription, context.ErrorUri), + context.Error, context.ErrorDescription, context.ErrorUri); + } - foreach (var claim in principal.Claims) - { - // If a claim with the same type and the same value already exist, skip it. - if (identity.HasClaim(claim.Type, claim.Value)) - { - continue; - } + Debug.Assert(context.TokenResponse is not null, SR.GetResourceString(SR.ID4007)); - identity.AddClaim(claim); - } + // Create a composite principal containing claims resolved from the + // backchannel identity token and the userinfo token, if available. + return (context.TokenResponse, CreatePrincipal( + context.BackchannelIdentityTokenPrincipal, + context.UserinfoTokenPrincipal)); + } + + finally + { + if (scope is IAsyncDisposable disposable) + { + await disposable.DisposeAsync(); } - return new ClaimsPrincipal(identity); + else + { + scope.Dispose(); + } } } @@ -789,4 +842,43 @@ public class OpenIddictClientService } } } + + private static ClaimsPrincipal CreatePrincipal(params ClaimsPrincipal?[] principals) + { + // Note: the OpenIddict client handler can be used as a pure OAuth 2.0-only stack for + // delegation scenarios where the identity of the user is not needed. In this case, + // since no principal can be resolved from a token or a userinfo response to construct + // a user identity, a fake one containing an "unauthenticated" identity (i.e with its + // AuthenticationType property deliberately left to null) is used to allow ASP.NET Core + // to return a "successful" authentication result for these delegation-only scenarios. + if (!principals.Any(principal => principal?.Identity is ClaimsIdentity { IsAuthenticated: true })) + { + return new ClaimsPrincipal(new ClaimsIdentity()); + } + + // Create a new composite identity containing the claims of all the principals. + var identity = new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType); + + foreach (var principal in principals) + { + // Note: the principal may be null if no value was extracted from the corresponding token. + if (principal is null) + { + continue; + } + + foreach (var claim in principal.Claims) + { + // If a claim with the same type and the same value already exist, skip it. + if (identity.HasClaim(claim.Type, claim.Value)) + { + continue; + } + + identity.AddClaim(claim); + } + } + + return new ClaimsPrincipal(identity); + } }