From 9deb68d433d2f9a691290a2aca0e592a331d8091 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Chalet?= Date: Thu, 27 Jul 2023 07:26:18 +0200 Subject: [PATCH] Add an automatic claims mapping feature to the OpenIddict client stack --- .../Controllers/AuthenticationController.cs | 50 +--- .../Controllers/AuthenticationController.cs | 46 +--- .../Controllers/AuthenticationController.cs | 59 ++--- .../Controllers/AuthenticationController.cs | 54 ++--- .../Helpers/OpenIddictHelpers.cs | 44 ---- .../OpenIddictClientAspNetCoreHandler.cs | 34 +-- .../OpenIddictClientOwinHandler.cs | 29 +-- ...enIddictClientSystemIntegrationHandlers.cs | 50 ++++ ...ctClientWebIntegrationHandlers.Userinfo.cs | 18 +- .../OpenIddictClientWebIntegrationHandlers.cs | 217 +++++++++++++++++- .../OpenIddictClientBuilder.cs | 16 ++ .../OpenIddictClientEvents.cs | 5 + .../OpenIddictClientExtensions.cs | 1 + .../OpenIddictClientHandlerFilters.cs | 17 ++ .../OpenIddictClientHandlers.Userinfo.cs | 12 +- .../OpenIddictClientHandlers.cs | 159 +++++++++++++ .../OpenIddictClientOptions.cs | 12 + .../OpenIddictClientService.cs | 32 +-- ...nIddictValidationHandlers.Introspection.cs | 7 +- 19 files changed, 547 insertions(+), 315 deletions(-) diff --git a/sandbox/OpenIddict.Sandbox.AspNet.Client/Controllers/AuthenticationController.cs b/sandbox/OpenIddict.Sandbox.AspNet.Client/Controllers/AuthenticationController.cs index 481fd12c..4a9c61e8 100644 --- a/sandbox/OpenIddict.Sandbox.AspNet.Client/Controllers/AuthenticationController.cs +++ b/sandbox/OpenIddict.Sandbox.AspNet.Client/Controllers/AuthenticationController.cs @@ -173,47 +173,15 @@ namespace OpenIddict.Sandbox.AspNet.Client.Controllers // // By default, all claims extracted during the authorization dance are available. The claims collection stored // in the cookie can be filtered out or mapped to different names depending the claim name or its issuer. - var claims = new List(result.Identity.Claims - .Select(claim => claim switch - { - // Map the standard "sub" and custom "id" claims to ClaimTypes.NameIdentifier, which is - // the default claim type used by .NET and is required by the antiforgery components. - { Type: Claims.Subject } or - { Type: "id", Issuer: "https://github.com/" or "https://twitter.com/" } - => new Claim(ClaimTypes.NameIdentifier, claim.Value, claim.ValueType, claim.Issuer), - - // Map the standard "name" claim to ClaimTypes.Name. - { Type: Claims.Name } - => new Claim(ClaimTypes.Name, claim.Value, claim.ValueType, claim.Issuer), - - // The antiforgery components require an "identityprovider" claim, which - // is mapped from the authorization server claim returned by OpenIddict. - { Type: Claims.AuthorizationServer } - => new Claim("http://schemas.microsoft.com/accesscontrolservice/2010/07/claims/identityprovider", - claim.Value, claim.ValueType, claim.Issuer), - - _ => claim - }) - .Where(claim => claim switch - { - // Preserve the basic claims that are necessary for the application to work correctly. - { - Type: ClaimTypes.NameIdentifier or - ClaimTypes.Name or - "http://schemas.microsoft.com/accesscontrolservice/2010/07/claims/identityprovider" - } => true, - - // Preserve the client registration identifier as a dedicated claim so that the - // associated server configuration can be resolved from the logout endpoint to - // determine whether the authorization server supports client-initiated logouts. - { Type: Claims.Private.RegistrationId } => true, - - // Applications that use multiple client registrations can filter claims based on the issuer. - { Type: "bio", Issuer: "https://github.com/" } => true, - - // Don't preserve the other claims. - _ => false - })); + var claims = result.Identity.Claims.Where(claim => claim.Type is ClaimTypes.NameIdentifier or ClaimTypes.Name + // + // Preserve the registration identifier to be able to resolve it later. + // + or Claims.Private.RegistrationId + // + // The ASP.NET 4.x antiforgery module requires preserving the "identityprovider" claim. + // + or "http://schemas.microsoft.com/accesscontrolservice/2010/07/claims/identityprovider"); var identity = new ClaimsIdentity(claims, authenticationType: CookieAuthenticationDefaults.AuthenticationType, diff --git a/sandbox/OpenIddict.Sandbox.AspNet.Server/Controllers/AuthenticationController.cs b/sandbox/OpenIddict.Sandbox.AspNet.Server/Controllers/AuthenticationController.cs index fe4e6d81..68d1b481 100644 --- a/sandbox/OpenIddict.Sandbox.AspNet.Server/Controllers/AuthenticationController.cs +++ b/sandbox/OpenIddict.Sandbox.AspNet.Server/Controllers/AuthenticationController.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Linq; using System.Security.Claims; using System.Threading.Tasks; @@ -61,42 +60,15 @@ namespace OpenIddict.Sandbox.AspNet.Server.Controllers // // By default, all claims extracted during the authorization dance are available. The claims collection stored // in the cookie can be filtered out or mapped to different names depending the claim name or its issuer. - var claims = new List(result.Identity.Claims - .Select(claim => claim switch - { - // Map the standard "sub" and custom "id" claims to ClaimTypes.NameIdentifier, which is - // the default claim type used by .NET and is required by the antiforgery components. - { Type: Claims.Subject } or - { Type: "id", Issuer: "https://github.com/" } - => new Claim(ClaimTypes.NameIdentifier, claim.Value, claim.ValueType, claim.Issuer), - - // Map the standard "name" claim to ClaimTypes.Name. - { Type: Claims.Name } - => new Claim(ClaimTypes.Name, claim.Value, claim.ValueType, claim.Issuer), - - // The antiforgery components require an "identityprovider" claim, which - // is mapped from the authorization server claim returned by OpenIddict. - { Type: Claims.AuthorizationServer } - => new Claim("http://schemas.microsoft.com/accesscontrolservice/2010/07/claims/identityprovider", - claim.Value, claim.ValueType, claim.Issuer), - - _ => claim - }) - .Where(claim => claim switch - { - // Preserve the basic claims that are necessary for the application to work correctly. - { - Type: ClaimTypes.NameIdentifier or - ClaimTypes.Name or - "http://schemas.microsoft.com/accesscontrolservice/2010/07/claims/identityprovider" - } => true, - - // Applications that use multiple client registrations can filter claims based on the issuer. - { Type: "bio", Issuer: "https://github.com/" } => true, - - // Don't preserve the other claims. - _ => false - })); + var claims = result.Identity.Claims.Where(claim => claim.Type is ClaimTypes.NameIdentifier or ClaimTypes.Name + // + // Preserve the registration identifier to be able to resolve it later. + // + or Claims.Private.RegistrationId + // + // The ASP.NET 4.x antiforgery module requires preserving the "identityprovider" claim. + // + or "http://schemas.microsoft.com/accesscontrolservice/2010/07/claims/identityprovider"); // Note: when using external authentication providers with ASP.NET Identity, // the user identity MUST be added to the external authentication cookie scheme. diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Controllers/AuthenticationController.cs b/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Controllers/AuthenticationController.cs index c2e157db..35b192d4 100644 --- a/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Controllers/AuthenticationController.cs +++ b/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Controllers/AuthenticationController.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Mvc; +using OpenIddict.Abstractions; using OpenIddict.Client; using OpenIddict.Client.AspNetCore; using static OpenIddict.Abstractions.OpenIddictConstants; @@ -159,44 +160,16 @@ public class AuthenticationController : Controller // Build an identity based on the external claims and that will be used to create the authentication cookie. // - // By default, all claims extracted during the authorization dance are available. The claims collection stored - // in the cookie can be filtered out or mapped to different names depending the claim name or its issuer. - var claims = new List(result.Principal.Claims - .Select(claim => claim switch - { - // Map the standard "sub" and custom "id" claims to ClaimTypes.NameIdentifier, which is - // the default claim type used by .NET and is required by the antiforgery components. - { Type: Claims.Subject } or - { Type: "id", Issuer: "https://github.com/" or "https://twitter.com/" } - => new Claim(ClaimTypes.NameIdentifier, claim.Value, claim.ValueType, claim.Issuer), - - // Map the standard "name" claim to ClaimTypes.Name. - { Type: Claims.Name } - => new Claim(ClaimTypes.Name, claim.Value, claim.ValueType, claim.Issuer), - - _ => claim - }) - .Where(claim => claim switch - { - // Preserve the basic claims that are necessary for the application to work correctly. - { Type: ClaimTypes.NameIdentifier or ClaimTypes.Name } => true, - - // Preserve the client registration identifier as a dedicated claim so that the - // associated server configuration can be resolved from the logout endpoint to - // determine whether the authorization server supports client-initiated logouts. - { Type: Claims.Private.RegistrationId } => true, - - // Applications that use multiple client registrations can filter claims based on the issuer. - { Type: "bio", Issuer: "https://github.com/" } => true, - - // Don't preserve the other claims. - _ => false - })); - - var identity = new ClaimsIdentity(claims, - authenticationType: CookieAuthenticationDefaults.AuthenticationScheme, - nameType: ClaimTypes.Name, - roleType: ClaimTypes.Role); + // By default, OpenIddict will automatically try to map the email/name and name identifier claims from + // their standard OpenID Connect or provider-specific equivalent, if available. If needed, additional + // claims can be resolved from the external identity and copied to the final authentication cookie. + var identity = new ClaimsIdentity(CookieAuthenticationDefaults.AuthenticationScheme) + .SetClaim(ClaimTypes.Email, result.Principal.GetClaim(ClaimTypes.Email)) + .SetClaim(ClaimTypes.Name, result.Principal.GetClaim(ClaimTypes.Name)) + .SetClaim(ClaimTypes.NameIdentifier, result.Principal.GetClaim(ClaimTypes.NameIdentifier)); + + // Preserve the registration identifier to be able to resolve it later. + identity.SetClaim(Claims.Private.RegistrationId, result.Principal.GetClaim(Claims.Private.RegistrationId)); // Build the authentication properties based on the properties that were added when the challenge was triggered. var properties = new AuthenticationProperties(result.Properties.Items) @@ -205,6 +178,7 @@ public class AuthenticationController : Controller }; // If needed, the tokens returned by the authorization server can be stored in the authentication cookie. + // // To make cookies less heavy, tokens that are not used are filtered out before creating the cookie. properties.StoreTokens(result.Properties.GetTokens().Where(token => token switch { @@ -219,9 +193,12 @@ public class AuthenticationController : Controller _ => false })); - // Ask the cookie authentication handler to return a new cookie and redirect - // the user agent to the return URL stored in the authentication properties. - return SignIn(new ClaimsPrincipal(identity), properties, CookieAuthenticationDefaults.AuthenticationScheme); + // Ask the default sign-in handler to return a new cookie and redirect the + // user agent to the return URL stored in the authentication properties. + // + // For scenarios where the default sign-in handler configured in the ASP.NET Core + // authentication options shouldn't be used, a specific scheme can be specified here. + return SignIn(new ClaimsPrincipal(identity), properties); } // Note: this controller uses the same callback action for all providers diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Controllers/AuthenticationController.cs b/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Controllers/AuthenticationController.cs index 62c916d1..38e6c5ba 100644 --- a/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Controllers/AuthenticationController.cs +++ b/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Controllers/AuthenticationController.cs @@ -1,7 +1,9 @@ using System.Security.Claims; using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using OpenIddict.Abstractions; using OpenIddict.Client.AspNetCore; using static OpenIddict.Abstractions.OpenIddictConstants; @@ -52,41 +54,16 @@ public class AuthenticationController : Controller // Build an identity based on the external claims and that will be used to create the authentication cookie. // - // By default, all claims extracted during the authorization dance are available. The claims collection stored - // in the cookie can be filtered out or mapped to different names depending the claim name or its issuer. - var claims = new List(result.Principal.Claims - .Select(claim => claim switch - { - // Map the standard "sub" and custom "id" claims to ClaimTypes.NameIdentifier, which is - // the default claim type used by .NET and is required by the antiforgery components. - { Type: Claims.Subject } or - { Type: "id", Issuer: "https://github.com/" or "https://twitter.com/" } - => new Claim(ClaimTypes.NameIdentifier, claim.Value, claim.ValueType, claim.Issuer), - - // Map the standard "name" claim to ClaimTypes.Name. - { Type: Claims.Name } - => new Claim(ClaimTypes.Name, claim.Value, claim.ValueType, claim.Issuer), - - _ => claim - }) - .Where(claim => claim switch - { - // Preserve the basic claims that are necessary for the application to work correctly. - { Type: ClaimTypes.NameIdentifier or ClaimTypes.Name } => true, + // By default, OpenIddict will automatically try to map the email/name and name identifier claims from + // their standard OpenID Connect or provider-specific equivalent, if available. If needed, additional + // claims can be resolved from the external identity and copied to the final authentication cookie. + var identity = new ClaimsIdentity(CookieAuthenticationDefaults.AuthenticationScheme) + .SetClaim(ClaimTypes.Email, result.Principal.GetClaim(ClaimTypes.Email)) + .SetClaim(ClaimTypes.Name, result.Principal.GetClaim(ClaimTypes.Name)) + .SetClaim(ClaimTypes.NameIdentifier, result.Principal.GetClaim(ClaimTypes.NameIdentifier)); - // Applications that use multiple client registrations can filter claims based on the issuer. - { Type: "bio", Issuer: "https://github.com/" } => true, - - // Don't preserve the other claims. - _ => false - })); - - // Note: when using external authentication providers with ASP.NET Core Identity, - // the user identity MUST be added to the external authentication cookie scheme. - var identity = new ClaimsIdentity(claims, - authenticationType: IdentityConstants.ExternalScheme, - nameType: ClaimTypes.NameIdentifier, - roleType: ClaimTypes.Role); + // Preserve the registration identifier to be able to resolve it later. + identity.SetClaim(Claims.Private.RegistrationId, result.Principal.GetClaim(Claims.Private.RegistrationId)); // Build the authentication properties based on the properties that were added when the challenge was triggered. var properties = new AuthenticationProperties(result.Properties.Items) @@ -108,8 +85,11 @@ public class AuthenticationController : Controller _ => false })); - // Ask the cookie authentication handler to return a new cookie and redirect - // the user agent to the return URL stored in the authentication properties. - return SignIn(new ClaimsPrincipal(identity), properties, IdentityConstants.ExternalScheme); + // Ask the default sign-in handler to return a new cookie and redirect the + // user agent to the return URL stored in the authentication properties. + // + // For scenarios where the default sign-in handler configured in the ASP.NET Core + // authentication options shouldn't be used, a specific scheme can be specified here. + return SignIn(new ClaimsPrincipal(identity), properties); } } diff --git a/shared/OpenIddict.Extensions/Helpers/OpenIddictHelpers.cs b/shared/OpenIddict.Extensions/Helpers/OpenIddictHelpers.cs index dbdacb13..ba666793 100644 --- a/shared/OpenIddict.Extensions/Helpers/OpenIddictHelpers.cs +++ b/shared/OpenIddict.Extensions/Helpers/OpenIddictHelpers.cs @@ -376,50 +376,6 @@ internal static class OpenIddictHelpers .ToDictionary(pair => pair.Key!, pair => new StringValues(pair.Select(parts => parts.Value).ToArray())); } - /// - /// Creates a merged principal based on the specified principals. - /// - /// The collection of principals to merge. - /// The merged principal. - public static ClaimsPrincipal CreateMergedPrincipal(params ClaimsPrincipal?[] principals) - { - // Note: components like the client handler can be used as a pure OAuth 2.0 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 the host - // to return a "successful" authentication result for these delegation-only scenarios. - if (!Array.Exists(principals, static 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); - } - #if SUPPORTS_ECDSA /// /// Creates a new key. diff --git a/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreHandler.cs b/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreHandler.cs index 6cb40923..2d546cde 100644 --- a/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreHandler.cs +++ b/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreHandler.cs @@ -5,14 +5,12 @@ */ using System.ComponentModel; -using System.Diagnostics; using System.Globalization; using System.Security.Claims; using System.Text.Encodings.Web; using System.Text.Json; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using OpenIddict.Extensions; using static OpenIddict.Client.AspNetCore.OpenIddictClientAspNetCoreConstants; using Properties = OpenIddict.Client.AspNetCore.OpenIddictClientAspNetCoreConstants.Properties; @@ -149,41 +147,15 @@ public sealed class OpenIddictClientAspNetCoreHandler : AuthenticationHandler OpenIddictHelpers.CreateMergedPrincipal( - context.FrontchannelIdentityTokenPrincipal, - context.BackchannelIdentityTokenPrincipal, - context.UserinfoTokenPrincipal), - - OpenIddictClientEndpointType.PostLogoutRedirection => context.StateTokenPrincipal, - - _ => null - }; - - if (principal is null) + if (context.MergedPrincipal is not ClaimsPrincipal principal) { return AuthenticateResult.NoResult(); } - // Attach the registration identifier and identity of the authorization server to the returned principal to allow - // resolving it even if no other claim was added (e.g if no id_token was returned/no userinfo endpoint is available). - principal.SetClaim(Claims.AuthorizationServer, context.Registration.Issuer.AbsoluteUri) - .SetClaim(Claims.Private.RegistrationId, context.Registration.RegistrationId) - .SetClaim(Claims.Private.ProviderName, context.Registration.ProviderName); - // Restore or create a new authentication properties collection and populate it. var properties = CreateProperties(context.StateTokenPrincipal); - properties.ExpiresUtc = principal.GetExpirationDate(); - properties.IssuedUtc = principal.GetCreationDate(); + properties.ExpiresUtc = context.StateTokenPrincipal?.GetExpirationDate(); + properties.IssuedUtc = context.StateTokenPrincipal?.GetCreationDate(); // Restore the target link URI that was stored in the state // token when the challenge operation started, if available. diff --git a/src/OpenIddict.Client.Owin/OpenIddictClientOwinHandler.cs b/src/OpenIddict.Client.Owin/OpenIddictClientOwinHandler.cs index 34639b7d..d2e304c6 100644 --- a/src/OpenIddict.Client.Owin/OpenIddictClientOwinHandler.cs +++ b/src/OpenIddict.Client.Owin/OpenIddictClientOwinHandler.cs @@ -170,38 +170,15 @@ public sealed class OpenIddictClientOwinHandler : AuthenticationHandler OpenIddictHelpers.CreateMergedPrincipal( - context.FrontchannelIdentityTokenPrincipal, - context.BackchannelIdentityTokenPrincipal, - context.UserinfoTokenPrincipal), - - OpenIddictClientEndpointType.PostLogoutRedirection => context.StateTokenPrincipal, - - _ => null - }; - - if (principal is null) + if (context.MergedPrincipal is not ClaimsPrincipal principal) { return null; } - // Attach the registration identifier and identity of the authorization server to the returned principal to allow - // resolving it even if no other claim was added (e.g if no id_token was returned/no userinfo endpoint is available). - principal.SetClaim(Claims.AuthorizationServer, context.Registration.Issuer.AbsoluteUri) - .SetClaim(Claims.Private.RegistrationId, context.Registration.RegistrationId) - .SetClaim(Claims.Private.ProviderName, context.Registration.ProviderName); - // Restore or create a new authentication properties collection and populate it. var properties = CreateProperties(context.StateTokenPrincipal); - properties.ExpiresUtc = principal.GetExpirationDate(); - properties.IssuedUtc = principal.GetCreationDate(); + properties.ExpiresUtc = context.StateTokenPrincipal?.GetExpirationDate(); + properties.IssuedUtc = context.StateTokenPrincipal?.GetCreationDate(); // Restore the target link URI that was stored in the state // token when the challenge operation started, if available. diff --git a/src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationHandlers.cs b/src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationHandlers.cs index 89776d12..f4d97ff9 100644 --- a/src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationHandlers.cs +++ b/src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationHandlers.cs @@ -45,11 +45,14 @@ public static partial class OpenIddictClientSystemIntegrationHandlers * Authentication processing: */ WaitMarshalledAuthentication.Descriptor, + RestoreStateTokenFromMarshalledAuthentication.Descriptor, RestoreStateTokenPrincipalFromMarshalledAuthentication.Descriptor, RestoreClientRegistrationFromMarshalledContext.Descriptor, + RedirectProtocolActivation.Descriptor, ResolveRequestForgeryProtection.Descriptor, + RestoreFrontchannelTokensFromMarshalledAuthentication.Descriptor, RestoreFrontchannelIdentityTokenPrincipalFromMarshalledAuthentication.Descriptor, RestoreFrontchannelAccessTokenPrincipalFromMarshalledAuthentication.Descriptor, @@ -60,6 +63,8 @@ public static partial class OpenIddictClientSystemIntegrationHandlers RestoreBackchannelAccessTokenPrincipalFromMarshalledAuthentication.Descriptor, RestoreRefreshTokenPrincipalFromMarshalledAuthentication.Descriptor, RestoreUserinfoDetailsFromMarshalledAuthentication.Descriptor, + RestoreMergedPrincipalFromMarshalledAuthentication.Descriptor, + CompleteAuthenticationOperation.Descriptor, UntrackMarshalledAuthenticationOperation.Descriptor, @@ -1353,6 +1358,51 @@ public static partial class OpenIddictClientSystemIntegrationHandlers } } + /// + /// Contains the logic responsible for restoring the merged principal from the marshalled authentication context, if applicable. + /// + public sealed class RestoreMergedPrincipalFromMarshalledAuthentication : IOpenIddictClientHandler + { + private readonly OpenIddictClientSystemIntegrationMarshal _marshal; + + public RestoreMergedPrincipalFromMarshalledAuthentication(OpenIddictClientSystemIntegrationMarshal marshal) + => _marshal = marshal ?? throw new ArgumentNullException(nameof(marshal)); + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(PopulateMergedPrincipal.Descriptor.Order + 500) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessAuthenticationContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(!string.IsNullOrEmpty(context.Nonce), SR.GetResourceString(SR.ID4019)); + + context.MergedPrincipal = context.EndpointType switch + { + // When the authentication context is marshalled, restore the merged principal from the other instance. + OpenIddictClientEndpointType.Unknown when _marshal.TryGetResult(context.Nonce, out var notification) + => notification.MergedPrincipal, + + // Otherwise, don't alter the current context. + _ => context.MergedPrincipal + }; + + return default; + } + } + /// /// Contains the logic responsible for informing the authentication service the operation is complete. /// diff --git a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Userinfo.cs b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Userinfo.cs index 04b1720c..f4e9f173 100644 --- a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Userinfo.cs +++ b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Userinfo.cs @@ -384,14 +384,11 @@ public static partial class OpenIddictClientWebIntegrationHandlers ["accounts"] = context.Response["accounts"] }, - // Kroger and Twitter return a nested "data" object. - ProviderTypes.Kroger or ProviderTypes.Twitter => new(context.Response["data"]?.GetNamedParameters() ?? + // Kroger, Twitter and Patreon return a nested "data" object. + ProviderTypes.Kroger or ProviderTypes.Patreon or ProviderTypes.Twitter + => new(context.Response["data"]?.GetNamedParameters() ?? throw new InvalidOperationException(SR.FormatID0334("data"))), - // Patreon returns a nested "attributes" object that is itself nested in a "data" node. - ProviderTypes.Patreon => new(context.Response["data"]?["attributes"]?.GetNamedParameters() ?? - throw new InvalidOperationException(SR.FormatID0334("data/attributes"))), - // ServiceChannel returns a nested "UserProfile" object. ProviderTypes.ServiceChannel => new(context.Response["UserProfile"]?.GetNamedParameters() ?? throw new InvalidOperationException(SR.FormatID0334("UserProfile"))), @@ -400,15 +397,6 @@ public static partial class OpenIddictClientWebIntegrationHandlers ProviderTypes.StackExchange => new(context.Response["items"]?[0]?.GetNamedParameters() ?? throw new InvalidOperationException(SR.FormatID0334("items/0"))), - // Streamlabs splits the user data into multiple service-specific nodes (e.g "twitch"/"facebook"). - // - // To make claims easier to use, the parameters are flattened and prefixed with the service name. - ProviderTypes.Streamlabs => new( - from parameter in context.Response.GetParameters() - from node in parameter.Value.GetNamedParameters() - let name = $"{parameter.Key}_{node.Key}" - select new KeyValuePair(name, node.Value)), - // SubscribeStar returns a nested "user" object that is itself nested in a GraphQL "data" node. ProviderTypes.SubscribeStar => new(context.Response["data"]?["user"]?.GetNamedParameters() ?? throw new InvalidOperationException(SR.FormatID0334("data/user"))), diff --git a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.cs b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.cs index 7edf8a9c..ee769934 100644 --- a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.cs +++ b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.cs @@ -36,6 +36,7 @@ public static partial class OpenIddictClientWebIntegrationHandlers DisableUserinfoValidation.Descriptor, AttachAdditionalUserinfoRequestParameters.Descriptor, PopulateUserinfoTokenPrincipalFromTokenResponse.Descriptor, + MapCustomWebServicesFederationClaims.Descriptor, /* * Challenge processing: @@ -867,6 +868,7 @@ public static partial class OpenIddictClientWebIntegrationHandlers throw new ArgumentNullException(nameof(context)); } + Debug.Assert(context.Registration.Issuer is { IsAbsoluteUri: true }, SR.GetResourceString(SR.ID4013)); Debug.Assert(context.TokenResponse is not null, SR.GetResourceString(SR.ID4007)); // Don't overwrite the userinfo token principal if one was already set. @@ -912,8 +914,6 @@ public static partial class OpenIddictClientWebIntegrationHandlers context.Registration.TokenValidationParameters.NameClaimType, context.Registration.TokenValidationParameters.RoleClaimType); - var issuer = context.Configuration.Issuer!.AbsoluteUri; - foreach (var parameter in parameters) { // Note: in the typical case, the response parameters should be deserialized from a @@ -928,11 +928,11 @@ public static partial class OpenIddictClientWebIntegrationHandlers // Top-level claims represented as arrays are split and mapped to multiple CLR claims // to match the logic implemented by IdentityModel for JWT token deserialization. case { ValueKind: JsonValueKind.Array } value: - identity.AddClaims(parameter.Key, value, issuer); + identity.AddClaims(parameter.Key, value, context.Registration.Issuer.AbsoluteUri); break; case { ValueKind: _ } value: - identity.AddClaim(parameter.Key, value, issuer); + identity.AddClaim(parameter.Key, value, context.Registration.Issuer.AbsoluteUri); break; } } @@ -943,6 +943,215 @@ public static partial class OpenIddictClientWebIntegrationHandlers } } + /// + /// Contains the logic responsible for mapping select custom claims to + /// their WS-Federation equivalent for the providers that require it. + /// + public sealed class MapCustomWebServicesFederationClaims : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(MapStandardWebServicesFederationClaims.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessAuthenticationContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(context.Registration.Issuer is { IsAbsoluteUri: true }, SR.GetResourceString(SR.ID4013)); + + // As an OpenID Connect framework, the OpenIddict client mostly uses the claim set defined by the OpenID + // Connect core specification (https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims). + // While these claims can be easily accessed using their standard OIDC name, many components still use + // the Web Services Federation claims exposed by the BCL ClaimTypes class, sometimes without allowing + // to use different claim types (e.g ASP.NET Core Identity hardcodes ClaimTypes.NameIdentifier in a few + // places, like the GetUserId() extension). To reduce the difficulty of using the OpenIddict client with + // these components relying on WS-Federation-style claims, these claims are mapped from the custom, + // provider-specific parameters (either from the userinfo response or from the token rensponse). + // + // Note: a similar event handler exists in OpenIddict.Client to map these claims from + // the standard OpenID Connect claim types (see MapStandardWebServicesFederationClaims). + + var issuer = context.Registration.Issuer.AbsoluteUri; + + context.MergedPrincipal.SetClaim(ClaimTypes.Email, issuer: issuer, value: context.Registration.ProviderType switch + { + // Basecamp returns the email address as a custom "email_address" node: + ProviderTypes.Basecamp => (string?) context.UserinfoResponse?["email_address"], + + // HubSpot returns the email address as a custom "user" node: + ProviderTypes.HubSpot => (string?) context.UserinfoResponse?["user"], + + // Mailchimp returns the email address as a custom "login/login_email" node: + ProviderTypes.Mailchimp => (string?) context.UserinfoResponse?["login"]?["login_email"], + + // Notion returns the email address as a custom "bot/owner/user/person/email" node + // but requires a special capability to access this node, that may not be present: + ProviderTypes.Notion => (string?) context.UserinfoResponse?["bot"]?["owner"]?["user"]?["person"]?["email"], + + // Patreon returns the email address as a custom "attributes/email" node: + ProviderTypes.Patreon => (string?) context.UserinfoResponse?["attributes"]?["email"], + + // ServiceChannel returns the email address as a custom "Email" node: + ProviderTypes.ServiceChannel => (string?) context.UserinfoResponse?["Email"], + + // Shopify returns the email address as a custom "associated_user/email" node in token responses: + ProviderTypes.Shopify => (string?) context.TokenResponse?["associated_user"]?["email"], + + _ => context.MergedPrincipal.GetClaim(ClaimTypes.Email) + }); + + context.MergedPrincipal.SetClaim(ClaimTypes.Name, issuer: issuer, value: context.Registration.ProviderType switch + { + // These providers return the username as a custom "username" node: + ProviderTypes.ArcGisOnline or ProviderTypes.Discord or ProviderTypes.DeviantArt or + ProviderTypes.Lichess or ProviderTypes.Mixcloud or ProviderTypes.Trakt or + ProviderTypes.WordPress + => (string?) context.UserinfoResponse?["username"], + + // Basecamp and Harvest don't return a username so one is created using the "first_name" and "last_name" nodes: + ProviderTypes.Basecamp or ProviderTypes.Harvest + when context.UserinfoResponse?.HasParameter("first_name") is true && + context.UserinfoResponse?.HasParameter("last_name") is true + => $"{(string?) context.UserinfoResponse?["first_name"]} {(string?) context.UserinfoResponse?["last_name"]}", + + // These providers return the username as a custom "name" node: + ProviderTypes.Deezer or ProviderTypes.Facebook or ProviderTypes.Reddit or + ProviderTypes.SubscribeStar or ProviderTypes.Vimeo + => (string?) context.UserinfoResponse?["name"], + + // FitBit returns the username as a custom "displayName" node: + ProviderTypes.Fitbit => (string?) context.UserinfoResponse?["displayName"], + + // HubSpot returns the username as a custom "user" node: + ProviderTypes.HubSpot => (string?) context.UserinfoResponse?["user"], + + // Mailchimp returns the username as a custom "accountname" node: + ProviderTypes.Mailchimp => (string?) context.UserinfoResponse?["accountname"], + + // Notion returns the username as a custom "bot/owner/user/name" node but + // requires a special capability to access this node, that may not be present: + ProviderTypes.Notion => (string?) context.UserinfoResponse?["bot"]?["owner"]?["user"]?["name"], + + // Patreon doesn't return a username and require using the complete user name as the username: + ProviderTypes.Patreon => (string?) context.UserinfoResponse?["attributes"]?["full_name"], + + // ServiceChannel returns the username as a custom "UserName" node: + ProviderTypes.ServiceChannel => (string?) context.UserinfoResponse?["UserName"], + + // Shopify doesn't return a username so one is created using the "first_name" and "last_name" nodes: + ProviderTypes.Shopify + when context.TokenResponse?["associated_user"]?["first_name"] is not null && + context.TokenResponse?["associated_user"]?["last_name"] is not null + => $"{(string?) context.TokenResponse?["associated_user"]?["first_name"]} {(string?) context.TokenResponse?["associated_user"]?["last_name"]}", + + // Smartsheet doesn't return a username so one is created using the "firstName" and "lastName" nodes: + ProviderTypes.Smartsheet + when context.UserinfoResponse?.HasParameter("firstName") is true && + context.UserinfoResponse?.HasParameter("lastName") is true + => $"{(string?) context.UserinfoResponse?["firstName"]} {(string?) context.UserinfoResponse?["lastName"]}", + + // Spotify and StackExchange return the username as a custom "display_name" node: + ProviderTypes.Spotify or ProviderTypes.StackExchange + => (string?) context.UserinfoResponse?["display_name"], + + // Strava returns the username as a custom "athlete/username" node in token responses: + ProviderTypes.Strava => (string?) context.TokenResponse?["athlete"]?["username"], + + // Streamlabs returns the username as a custom "streamlabs/display_name" node: + ProviderTypes.Streamlabs => (string?) context.UserinfoResponse?["streamlabs"]?["display_name"], + + // Trovo returns the username as a custom "userName" node: + ProviderTypes.Trovo => (string?) context.UserinfoResponse?["userName"], + + // Tumblr returns the username as a custom "name" node: + ProviderTypes.Tumblr => (string?) context.UserinfoResponse?["name"], + + _ => context.MergedPrincipal.GetClaim(ClaimTypes.Name) + }); + + context.MergedPrincipal.SetClaim(ClaimTypes.NameIdentifier, issuer: issuer, value: context.Registration.ProviderType switch + { + // ArcGIS and Trakt don't return a user identifier and require using the username as the identifier: + ProviderTypes.ArcGisOnline or ProviderTypes.Trakt + => (string?) context.UserinfoResponse?["username"], + + // These providers return the user identifier as a custom "id" node: + ProviderTypes.Basecamp or ProviderTypes.Deezer or ProviderTypes.Discord or + ProviderTypes.Facebook or ProviderTypes.GitHub or ProviderTypes.Harvest or + ProviderTypes.Kroger or ProviderTypes.Lichess or ProviderTypes.Twitter or + ProviderTypes.Patreon or ProviderTypes.Reddit or ProviderTypes.Smartsheet or + ProviderTypes.Spotify or ProviderTypes.SubscribeStar + => (string?) context.UserinfoResponse?["id"], + + // Bitbucket returns the user identifier as a custom "uuid" node: + ProviderTypes.Bitbucket => (string?) context.UserinfoResponse?["uuid"], + + // DeviantArt returns the user identifier as a custom "userid" node: + ProviderTypes.DeviantArt => (string?) context.UserinfoResponse?["userid"], + + // Fitbit returns the user identifier as a custom "encodedId" node: + ProviderTypes.Fitbit => (string?) context.UserinfoResponse?["encodedId"], + + // HubSpot and StackExchange return the user identifier as a custom "user_id" node: + ProviderTypes.HubSpot or ProviderTypes.StackExchange + => (string?) context.UserinfoResponse?["user_id"], + + // Mailchimp returns the user identifier as a custom "login/login_id" node: + ProviderTypes.Mailchimp => (string?) context.UserinfoResponse?["login"]?["login_id"], + + // Mixcloud returns the user identifier as a custom "key" node: + ProviderTypes.Mixcloud => (string?) context.UserinfoResponse?["key"], + + // Notion returns the user identifier as a custom "bot/owner/user/id" node but + // requires a special capability to access this node, that may not be present: + ProviderTypes.Notion => (string?) context.UserinfoResponse?["bot"]?["owner"]?["user"]?["id"], + + // ServiceChannel returns the user identifier as a custom "UserId" node: + ProviderTypes.ServiceChannel => (string?) context.UserinfoResponse?["UserId"], + + // Shopify returns the user identifier as a custom "associated_user/id" node in token responses: + ProviderTypes.Shopify => (string?) context.TokenResponse?["associated_user"]?["id"], + + // Strava returns the user identifier as a custom "athlete/id" node in token responses: + ProviderTypes.Strava => (string?) context.TokenResponse?["athlete"]?["id"], + + // Stripe returns the user identifier as a custom "stripe_user_id" node in token responses: + ProviderTypes.StripeConnect => (string?) context.TokenResponse?["stripe_user_id"], + + // Streamlabs returns the user identifier as a custom "streamlabs/id" node: + ProviderTypes.Streamlabs => (string?) context.UserinfoResponse?["streamlabs"]?["id"], + + // Trovo returns the user identifier as a custom "userId" node: + ProviderTypes.Trovo => (string?) context.UserinfoResponse?["userId"], + + // Tumblr doesn't return a user identifier and requires using the username as the identifier: + ProviderTypes.Tumblr => (string?) context.UserinfoResponse?["name"], + + // Vimeo returns the user identifier as a custom "uri" node, prefixed with "/users/": + ProviderTypes.Vimeo => (string?) context.UserinfoResponse?["uri"] is string uri && + uri.StartsWith("/users/", StringComparison.Ordinal) ? uri["/users/".Length..] : null, + + // WordPress returns the user identifier as a custom "ID" node: + ProviderTypes.WordPress => (string?) context.UserinfoResponse?["ID"], + + _ => context.MergedPrincipal.GetClaim(ClaimTypes.NameIdentifier) + }); + + return default; + } + } + /// /// Contains the logic responsible for validating the user-defined authentication properties. /// diff --git a/src/OpenIddict.Client/OpenIddictClientBuilder.cs b/src/OpenIddict.Client/OpenIddictClientBuilder.cs index ae4d5dbf..7e1c729d 100644 --- a/src/OpenIddict.Client/OpenIddictClientBuilder.cs +++ b/src/OpenIddict.Client/OpenIddictClientBuilder.cs @@ -9,6 +9,7 @@ using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Runtime.InteropServices; using System.Runtime.Versioning; +using System.Security.Claims; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -888,6 +889,21 @@ public sealed class OpenIddictClientBuilder public OpenIddictClientBuilder DisableTokenStorage() => Configure(options => options.DisableTokenStorage = true); + /// + /// Disables automatic claim mapping so that the merged principal returned by + /// OpenIddict after a successful authentication doesn't contain any WS-Federation + /// claims - exposed by the class - mapped from their + /// OpenID Connect/JSON Web Token or provider-specific equivalent. + /// + /// + /// Note: OpenID Connect/JSON Web Token or provider-specific claims that are mapped + /// to their WS-Federation equivalent are never removed from the merged principal + /// when automatic claim mapping is enabled. + /// + /// The instance. + public OpenIddictClientBuilder DisableWebServicesFederationClaimMapping() + => Configure(options => options.DisableWebServicesFederationClaimMapping = true); + /// /// Enables authorization code flow support. For more information /// about this specific OAuth 2.0/OpenID Connect flow, visit diff --git a/src/OpenIddict.Client/OpenIddictClientEvents.cs b/src/OpenIddict.Client/OpenIddictClientEvents.cs index ae2d627b..58b216fd 100644 --- a/src/OpenIddict.Client/OpenIddictClientEvents.cs +++ b/src/OpenIddict.Client/OpenIddictClientEvents.cs @@ -763,6 +763,11 @@ public static partial class OpenIddictClientEvents /// public ClaimsPrincipal? FrontchannelIdentityTokenPrincipal { get; set; } + /// + /// Gets or sets the merged principal containing the claims of the other principals. + /// + public ClaimsPrincipal MergedPrincipal { get; set; } = new ClaimsPrincipal(new ClaimsIdentity()); + /// /// Gets or sets the principal extracted from the refresh token, if applicable. /// diff --git a/src/OpenIddict.Client/OpenIddictClientExtensions.cs b/src/OpenIddict.Client/OpenIddictClientExtensions.cs index 0b0ce0b0..2eaf7f59 100644 --- a/src/OpenIddict.Client/OpenIddictClientExtensions.cs +++ b/src/OpenIddict.Client/OpenIddictClientExtensions.cs @@ -67,6 +67,7 @@ public static class OpenIddictClientExtensions builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); // Register the built-in client event handlers used by the OpenIddict client components. // Note: the order used here is not important, as the actual order is set in the options. diff --git a/src/OpenIddict.Client/OpenIddictClientHandlerFilters.cs b/src/OpenIddict.Client/OpenIddictClientHandlerFilters.cs index b7d738d8..232fe780 100644 --- a/src/OpenIddict.Client/OpenIddictClientHandlerFilters.cs +++ b/src/OpenIddict.Client/OpenIddictClientHandlerFilters.cs @@ -544,4 +544,21 @@ public static class OpenIddictClientHandlerFilters return new(!context.DisableUserinfoValidation); } } + + /// + /// Represents a filter that excludes the associated handlers if the WS-Federation claim mapping feature was disabled. + /// + public sealed class RequireWebServicesFederationClaimMappingEnabled : IOpenIddictClientHandlerFilter + { + /// + public ValueTask IsActiveAsync(ProcessAuthenticationContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + return new(!context.Options.DisableWebServicesFederationClaimMapping); + } + } } diff --git a/src/OpenIddict.Client/OpenIddictClientHandlers.Userinfo.cs b/src/OpenIddict.Client/OpenIddictClientHandlers.Userinfo.cs index 7f5bc196..54c125cf 100644 --- a/src/OpenIddict.Client/OpenIddictClientHandlers.Userinfo.cs +++ b/src/OpenIddict.Client/OpenIddictClientHandlers.Userinfo.cs @@ -5,6 +5,7 @@ */ using System.Collections.Immutable; +using System.Diagnostics; using System.Security.Claims; using System.Text.Json; using Microsoft.Extensions.Logging; @@ -158,6 +159,8 @@ public static partial class OpenIddictClientHandlers throw new ArgumentNullException(nameof(context)); } + Debug.Assert(context.Registration.Issuer is { IsAbsoluteUri: true }, SR.GetResourceString(SR.ID4013)); + // Ignore the response instance if a userinfo token was extracted. if (!string.IsNullOrEmpty(context.UserinfoToken)) { @@ -171,11 +174,6 @@ public static partial class OpenIddictClientHandlers context.Registration.TokenValidationParameters.NameClaimType, context.Registration.TokenValidationParameters.RoleClaimType); - // Resolve the issuer that will be attached to the claims created by this handler. - // Note: at this stage, the optional issuer extracted from the response is assumed - // to be valid, as it is guarded against unknown values by the ValidateIssuer handler. - var issuer = (string?) context.Response[Claims.Issuer] ?? context.Configuration.Issuer!.AbsoluteUri; - foreach (var parameter in context.Response.GetParameters()) { // Always exclude null keys as they can't be represented as valid claims. @@ -209,11 +207,11 @@ public static partial class OpenIddictClientHandlers // Top-level claims represented as arrays are split and mapped to multiple CLR claims // to match the logic implemented by IdentityModel for JWT token deserialization. case { ValueKind: JsonValueKind.Array } value: - identity.AddClaims(parameter.Key, value, issuer); + identity.AddClaims(parameter.Key, value, context.Registration.Issuer.AbsoluteUri); break; case { ValueKind: _ } value: - identity.AddClaim(parameter.Key, value, issuer); + identity.AddClaim(parameter.Key, value, context.Registration.Issuer.AbsoluteUri); break; } } diff --git a/src/OpenIddict.Client/OpenIddictClientHandlers.cs b/src/OpenIddict.Client/OpenIddictClientHandlers.cs index 1f5f0ac0..f7add9a7 100644 --- a/src/OpenIddict.Client/OpenIddictClientHandlers.cs +++ b/src/OpenIddict.Client/OpenIddictClientHandlers.cs @@ -93,6 +93,9 @@ public static partial class OpenIddictClientHandlers ValidateUserinfoTokenWellknownClaims.Descriptor, ValidateUserinfoTokenSubject.Descriptor, + PopulateMergedPrincipal.Descriptor, + MapStandardWebServicesFederationClaims.Descriptor, + /* * Challenge processing: */ @@ -3892,6 +3895,162 @@ public static partial class OpenIddictClientHandlers } } + /// + /// Contains the logic responsible for populating the merged principal from the other available principals. + /// + public sealed class PopulateMergedPrincipal : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(100_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessAuthenticationContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(context.Registration.Issuer is { IsAbsoluteUri: true }, SR.GetResourceString(SR.ID4013)); + + context.MergedPrincipal = context.EndpointType switch + { + // Create a composite principal containing claims resolved from the frontchannel + // and backchannel identity tokens and the userinfo token principal, if available. + OpenIddictClientEndpointType.Redirection => CreateMergedPrincipal( + context.FrontchannelIdentityTokenPrincipal, + context.BackchannelIdentityTokenPrincipal, + context.UserinfoTokenPrincipal), + + OpenIddictClientEndpointType.PostLogoutRedirection + => context.StateTokenPrincipal?.Clone() ?? new ClaimsPrincipal(new ClaimsIdentity()), + + _ => new ClaimsPrincipal(new ClaimsIdentity()) + }; + + // Attach the registration identifier and identity of the authorization server to the returned principal to allow + // resolving it even if no other claim was added (e.g if no id_token was returned/no userinfo endpoint is available). + context.MergedPrincipal.SetClaim(Claims.AuthorizationServer, context.Registration.Issuer.AbsoluteUri) + .SetClaim(Claims.Private.RegistrationId, context.Registration.RegistrationId) + .SetClaim(Claims.Private.ProviderName, context.Registration.ProviderName); + + return default; + + ClaimsPrincipal CreateMergedPrincipal(params ClaimsPrincipal?[] principals) + { + // Note: the OpenIddict client can be used as a pure OAuth 2.0 authorization 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 the host + // to return a "successful" authentication result for these delegation-only scenarios. + if (!Array.Exists(principals, static principal => principal?.Identity is ClaimsIdentity { IsAuthenticated: true })) + { + return new ClaimsPrincipal(new ClaimsIdentity()); + } + + // Create a new composite identity containing the claims of all the principals. + // + // Note: if WS-Federation claim mapping was not disabled, the resulting identity + // will use the default WS-Federation claims as the name/role claim types. + var identity = context.Options.DisableWebServicesFederationClaimMapping ? + new ClaimsIdentity( + context.Registration.TokenValidationParameters.AuthenticationType, + context.Registration.TokenValidationParameters.NameClaimType, + context.Registration.TokenValidationParameters.RoleClaimType) : + new ClaimsIdentity(context.Registration.TokenValidationParameters.AuthenticationType); + + 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); + } + } + } + + /// + /// Contains the logic responsible for mapping select standard claims to their WS-Federation equivalent, if applicable. + /// + public sealed class MapStandardWebServicesFederationClaims : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(PopulateMergedPrincipal.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessAuthenticationContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(context.Registration.Issuer is { IsAbsoluteUri: true }, SR.GetResourceString(SR.ID4013)); + + // As an OpenID Connect framework, the OpenIddict client mostly uses the claim set defined by the OpenID + // Connect core specification (https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims). + // While these claims can be easily accessed using their standard OIDC name, many components still use + // the Web Services Federation claims exposed by the BCL ClaimTypes class, sometimes without allowing + // to use different claim types (e.g ASP.NET Core Identity hardcodes ClaimTypes.NameIdentifier in a few + // places, like the GetUserId() extension). To reduce the difficulty of using the OpenIddict client with + // these components relying on WS-Federation-style claims, OpenIddict >= 4.7 integrates a built-in + // event handler that maps standard OpenID Connect claims to their Web Services Federation equivalent + // but deliberately doesn't remove the OpenID Connect claims from the resulting claims principal. + // + // Note: a similar event handler exists in OpenIddict.Client.WebIntegration to map these claims + // from non-standard/provider-specific claim types (see MapCustomWebServicesFederationClaims). + + var issuer = context.Registration.Issuer.AbsoluteUri; + + context.MergedPrincipal + .SetClaim(ClaimTypes.Email, context.MergedPrincipal.GetClaim(Claims.Email), issuer) + .SetClaim(ClaimTypes.Gender, context.MergedPrincipal.GetClaim(Claims.Gender), issuer) + .SetClaim(ClaimTypes.GivenName, context.MergedPrincipal.GetClaim(Claims.GivenName), issuer) + .SetClaim(ClaimTypes.Name, context.MergedPrincipal.GetClaim(Claims.PreferredUsername), issuer) + .SetClaim(ClaimTypes.NameIdentifier, context.MergedPrincipal.GetClaim(Claims.Subject), issuer) + .SetClaim(ClaimTypes.OtherPhone, context.MergedPrincipal.GetClaim(Claims.PhoneNumber), issuer) + .SetClaim(ClaimTypes.Surname, context.MergedPrincipal.GetClaim(Claims.FamilyName), issuer); + + // Note: while this claim is not exposed by the BCL ClaimTypes class, it is used by both ASP.NET Identity + // for ASP.NET 4.x and the System.Web.WebPages package, that requires it for antiforgery to work correctly. + context.MergedPrincipal.SetClaim("http://schemas.microsoft.com/accesscontrolservice/2010/07/claims/identityprovider", + context.MergedPrincipal.GetClaim(Claims.Private.ProviderName)); + + return default; + } + } + /// /// Contains the logic responsible for rejecting invalid challenge demands. /// diff --git a/src/OpenIddict.Client/OpenIddictClientOptions.cs b/src/OpenIddict.Client/OpenIddictClientOptions.cs index b73097f1..8da60e02 100644 --- a/src/OpenIddict.Client/OpenIddictClientOptions.cs +++ b/src/OpenIddict.Client/OpenIddictClientOptions.cs @@ -5,6 +5,7 @@ */ using System.ComponentModel; +using System.Security.Claims; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; @@ -124,6 +125,17 @@ public sealed class OpenIddictClientOptions /// public bool DisableTokenStorage { get; set; } + /// + /// Gets or sets a boolean indicating whether the claim mapping feature inferring + /// WS-Federation claims (exposed by the class) from their + /// OpenID Connect/JSON Web Token or provider-specific equivalent should be disabled. + /// + /// + /// Note: if automatic claim mapping is disabled, no WS-Federation claim will + /// be added to . + /// + public bool DisableWebServicesFederationClaimMapping { get; set; } + /// /// Gets the OAuth 2.0 code challenge methods enabled for this application. /// By default, only the S256 method is allowed (if the code flow is enabled). diff --git a/src/OpenIddict.Client/OpenIddictClientService.cs b/src/OpenIddict.Client/OpenIddictClientService.cs index eacdc482..6cded38e 100644 --- a/src/OpenIddict.Client/OpenIddictClientService.cs +++ b/src/OpenIddict.Client/OpenIddictClientService.cs @@ -348,12 +348,7 @@ public sealed class OpenIddictClientService FrontchannelAccessToken = context.FrontchannelAccessToken, FrontchannelIdentityToken = context.FrontchannelIdentityToken, FrontchannelIdentityTokenPrincipal = context.FrontchannelIdentityTokenPrincipal, - Principal = OpenIddictHelpers.CreateMergedPrincipal(context.FrontchannelIdentityTokenPrincipal, - context.BackchannelIdentityTokenPrincipal, - context.UserinfoTokenPrincipal) - .SetClaim(Claims.AuthorizationServer, context.Registration.Issuer.AbsoluteUri) - .SetClaim(Claims.Private.RegistrationId, context.Registration.RegistrationId) - .SetClaim(Claims.Private.ProviderName, context.Registration.ProviderName), + Principal = context.MergedPrincipal, Properties = context.Properties, RefreshToken = context.RefreshToken, StateTokenPrincipal = context.StateTokenPrincipal, @@ -597,11 +592,7 @@ public sealed class OpenIddictClientService AccessToken = context.BackchannelAccessToken!, IdentityToken = context.BackchannelIdentityToken, IdentityTokenPrincipal = context.BackchannelIdentityTokenPrincipal, - Principal = OpenIddictHelpers.CreateMergedPrincipal(context.BackchannelIdentityTokenPrincipal, - context.UserinfoTokenPrincipal) - .SetClaim(Claims.AuthorizationServer, context.Registration.Issuer.AbsoluteUri) - .SetClaim(Claims.Private.RegistrationId, context.Registration.RegistrationId) - .SetClaim(Claims.Private.ProviderName, context.Registration.ProviderName), + Principal = context.MergedPrincipal, Properties = context.Properties, RefreshToken = context.RefreshToken, TokenResponse = context.TokenResponse, @@ -769,11 +760,7 @@ public sealed class OpenIddictClientService AccessToken = context.BackchannelAccessToken!, IdentityToken = context.BackchannelIdentityToken, IdentityTokenPrincipal = context.BackchannelIdentityTokenPrincipal, - Principal = OpenIddictHelpers.CreateMergedPrincipal(context.BackchannelIdentityTokenPrincipal, - context.UserinfoTokenPrincipal) - .SetClaim(Claims.AuthorizationServer, context.Registration.Issuer.AbsoluteUri) - .SetClaim(Claims.Private.RegistrationId, context.Registration.RegistrationId) - .SetClaim(Claims.Private.ProviderName, context.Registration.ProviderName), + Principal = context.MergedPrincipal, Properties = context.Properties, RefreshToken = context.RefreshToken, TokenResponse = context.TokenResponse ?? new(), @@ -1117,11 +1104,7 @@ public sealed class OpenIddictClientService AccessToken = context.BackchannelAccessToken!, IdentityToken = context.BackchannelIdentityToken, IdentityTokenPrincipal = context.BackchannelIdentityTokenPrincipal, - Principal = OpenIddictHelpers.CreateMergedPrincipal(context.BackchannelIdentityTokenPrincipal, - context.UserinfoTokenPrincipal) - .SetClaim(Claims.AuthorizationServer, context.Registration.Issuer.AbsoluteUri) - .SetClaim(Claims.Private.RegistrationId, context.Registration.RegistrationId) - .SetClaim(Claims.Private.ProviderName, context.Registration.ProviderName), + Principal = context.MergedPrincipal, Properties = context.Properties, RefreshToken = context.RefreshToken, TokenResponse = context.TokenResponse, @@ -1282,12 +1265,7 @@ public sealed class OpenIddictClientService AccessToken = context.BackchannelAccessToken!, IdentityToken = context.BackchannelIdentityToken, IdentityTokenPrincipal = context.BackchannelIdentityTokenPrincipal, - Principal = OpenIddictHelpers.CreateMergedPrincipal( - context.BackchannelIdentityTokenPrincipal, - context.UserinfoTokenPrincipal) - .SetClaim(Claims.AuthorizationServer, context.Registration.Issuer.AbsoluteUri) - .SetClaim(Claims.Private.RegistrationId, context.Registration.RegistrationId) - .SetClaim(Claims.Private.ProviderName, context.Registration.ProviderName), + Principal = context.MergedPrincipal, Properties = context.Properties, RefreshToken = context.RefreshToken, TokenResponse = context.TokenResponse, diff --git a/src/OpenIddict.Validation/OpenIddictValidationHandlers.Introspection.cs b/src/OpenIddict.Validation/OpenIddictValidationHandlers.Introspection.cs index aee8e2d9..5227674b 100644 --- a/src/OpenIddict.Validation/OpenIddictValidationHandlers.Introspection.cs +++ b/src/OpenIddict.Validation/OpenIddictValidationHandlers.Introspection.cs @@ -424,11 +424,8 @@ public static partial class OpenIddictValidationHandlers context.Options.TokenValidationParameters.RoleClaimType); // Resolve the issuer that will be attached to the claims created by this handler. - // Note: at this stage, the optional issuer extracted from the response is assumed - // to be valid, as it is guarded against unknown values by the ValidateIssuer handler. - var issuer = (string?) context.Response[Claims.Issuer] ?? - context.Configuration.Issuer?.AbsoluteUri ?? - context.BaseUri?.AbsoluteUri ?? ClaimsIdentity.DefaultIssuer; + var issuer = context.Configuration.Issuer?.AbsoluteUri ?? + context.BaseUri?.AbsoluteUri ?? ClaimsIdentity.DefaultIssuer; foreach (var parameter in context.Response.GetParameters()) {