openiddict/openiddict-core

Inject role when token is validated

lufo88ita opened this issue · 3 comments

Confirm you've already contributed to this project or that you sponsor it

  • I confirm I'm a sponsor or a contributor

Version

5.7.0

Question

Hi again Kevin,

as a reminder:

My company sponsered you through the account SCP-srl, I'm a member of it.
https://github.com/orgs/SCP-srl/people

We have another problem with the same application which you help us to make work:
#2133

The authentication works like a charm ( :-) ), however we need to inject the roles. We won't (and cannot) use the roles in the authentication server, instead we retrieve them from a custom table of the application and inject their.

With the previous OIDC client we have an handler where we perform this injection. The code was like this:

Notifications = new OpenIdConnectAuthenticationNotifications
                {
                    SecurityTokenValidated = async context =>
                    {
                        var identity = context .AuthenticationTicket.Identity;

                        var serviceRoles = DependencyResolver.Current.GetService(typeof(IRoleService)) as IRoleService;
                        var currentUserSub = identity .Claims.First(c => c.Type == "sub").Value;
                        var roles = ur.GetRoleUser(currentUserSub);
                        foreach (var r in roles)
                        {
                            identity .AddClaim(new Claim("role", r.Name));
                        }
                        context.AuthenticationTicket = new AuthenticationTicket(identity, context.AuthenticationTicket.Properties);
                    },
                }
            });

We try to use your EventHandler, but we failed to understand which event is the equivalent of the old one.

I wrote this scoped handler

public class AddCustomRoleHandler : IOpenIddictClientHandler<?>
{
    public AddCustomRoleHandler() {
    }

    public ValueTask HandleAsync(HandleUserinfoResponseContext context)
    {
        var ruoliService = DependencyResolver.Current.GetService(typeof(IRoleService)) as IRoleService;
        var roles = ruoliService.GetRoleUser(context.Principal.Claims.First(c => c.Type == Claims.Subject).Value);

        context.Principal = new GenericPrincipal(context.Principal.Identity, roles.Select(r => r.Name).ToArray());
        return default;
    }
}

And added this

options.AddEventHandler<OpenIddictClientEvents.?>((builder) =>
{
    builder.UseScopedHandler<AddCustomRoleHandler>();
});

In the client definition, but I do not know which event to attach.

Hey @lufo88ita,

The authentication works like a charm ( :-) ), however we need to inject the roles. We won't (and cannot) use the roles in the authentication server, instead we retrieve them from a custom table of the application and inject their.

You could use the events model for that but the best option is to do it directly in the callback action of your authentication controller, where you have access to all the claims extracted by OpenIddict and can add any other claim you need - typically based on the user identity - before it is stored in the authentication cookie:

// Note: this controller uses the same callback action for all providers
// but for users who prefer using a different action per provider,
// the following action can be split into separate actions.
[HttpGet("~/callback/login/{provider}"), HttpPost("~/callback/login/{provider}"), IgnoreAntiforgeryToken]
public async Task<ActionResult> LogInCallback()
{
// Retrieve the authorization data validated by OpenIddict as part of the callback handling.
var result = await HttpContext.AuthenticateAsync(OpenIddictClientAspNetCoreDefaults.AuthenticationScheme);
// Multiple strategies exist to handle OAuth 2.0/OpenID Connect callbacks, each with their pros and cons:
//
// * Directly using the tokens to perform the necessary action(s) on behalf of the user, which is suitable
// for applications that don't need a long-term access to the user's resources or don't want to store
// access/refresh tokens in a database or in an authentication cookie (which has security implications).
// It is also suitable for applications that don't need to authenticate users but only need to perform
// action(s) on their behalf by making API calls using the access token returned by the remote server.
//
// * Storing the external claims/tokens in a database (and optionally keeping the essential claims in an
// authentication cookie so that cookie size limits are not hit). For the applications that use ASP.NET
// Core Identity, the UserManager.SetAuthenticationTokenAsync() API can be used to store external tokens.
//
// Note: in this case, it's recommended to use column encryption to protect the tokens in the database.
//
// * Storing the external claims/tokens in an authentication cookie, which doesn't require having
// a user database but may be affected by the cookie size limits enforced by most browser vendors
// (e.g Safari for macOS and Safari for iOS/iPadOS enforce a per-domain 4KB limit for all cookies).
//
// Note: this is the approach used here, but the external claims are first filtered to only persist
// a few claims like the user identifier. The same approach is used to store the access/refresh tokens.
// Important: if the remote server doesn't support OpenID Connect and doesn't expose a userinfo endpoint,
// result.Principal.Identity will represent an unauthenticated identity and won't contain any user claim.
//
// Such identities cannot be used as-is to build an authentication cookie in ASP.NET Core (as the
// antiforgery stack requires at least a name claim to bind CSRF cookies to the user's identity) but
// the access/refresh tokens can be retrieved using result.Properties.GetTokens() to make API calls.
if (result.Principal is not ClaimsPrincipal { Identity.IsAuthenticated: true })
{
throw new InvalidOperationException("The external authorization data cannot be used for authentication.");
}
// Build an identity based on the external claims and that will be used to create the authentication cookie.
var identity = new ClaimsIdentity(
authenticationType: "ExternalLogin",
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.
identity.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 details to be able to resolve them later.
identity.SetClaim(Claims.Private.RegistrationId, result.Principal.GetClaim(Claims.Private.RegistrationId))
.SetClaim(Claims.Private.ProviderName, result.Principal.GetClaim(Claims.Private.ProviderName));
// Important: when using ASP.NET Core Identity and its default UI, the identity created in this action is
// not directly persisted in the final authentication cookie (called "application cookie" by Identity) but
// in an intermediate authentication cookie called "external cookie" (the final authentication cookie is
// later created by Identity's ExternalLogin Razor Page by calling SignInManager.ExternalLoginSignInAsync()).
//
// Unfortunately, this process doesn't preserve the claims added here, which prevents flowing claims
// returned by the external provider down to the final authentication cookie. For scenarios that
// require that, the claims can be stored in Identity's database by calling UserManager.AddClaimAsync()
// directly in this action or by scaffolding the ExternalLogin.cshtml page that is part of the default UI:
// https://learn.microsoft.com/en-us/aspnet/core/security/authentication/social/additional-claims#add-and-update-user-claims.
//
// Alternatively, if flowing the claims from the "external cookie" to the "application cookie" is preferred,
// the default ExternalLogin.cshtml page provided by Identity can be scaffolded to replace the call to
// SignInManager.ExternalLoginSignInAsync() by a manual sign-in operation that will preserve the claims.
// For scenarios where scaffolding the ExternalLogin.cshtml page is not convenient, a custom SignInManager
// with an overridden SignInOrTwoFactorAsync() method can also be used to tweak the default Identity logic.
//
// For more information, see https://haacked.com/archive/2019/07/16/external-claims/ and
// https://stackoverflow.com/questions/42660568/asp-net-core-identity-extract-and-save-external-login-tokens-and-add-claims-to-l/42670559#42670559.
// Build the authentication properties based on the properties that were added when the challenge was triggered.
var properties = new AuthenticationProperties(result.Properties.Items)
{
RedirectUri = result.Properties.RedirectUri ?? "/"
};
// 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.Name is
// Preserve the access, identity and refresh tokens returned in the token response, if available.
OpenIddictClientAspNetCoreConstants.Tokens.BackchannelAccessToken or
OpenIddictClientAspNetCoreConstants.Tokens.BackchannelIdentityToken or
OpenIddictClientAspNetCoreConstants.Tokens.RefreshToken));
#if SUPPORTS_REDIRECTION_ON_SIGN_IN
// 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);
#else
// Note: "return SignIn(...)" cannot be directly used as-is on ASP.NET Core <7.0, as the cookies handler
// doesn't allow redirecting from an endpoint that doesn't match the path set in the cookie options.
await HttpContext.SignInAsync(new ClaimsPrincipal(identity), properties);
return Redirect(properties.RedirectUri ?? "/");
#endif
}

Hi, thanks for the reply. I apply your suggest.

Just a quick question: do you know why the method User.isInRole() check on the claim with the name
"http://schemas.microsoft.com/ws/2008/06/identity/claims/role"
and not
"role"
?

Forgot it I found the reason. I use the wrong constant while build the ClaimsIdentity. 👎 I will fix it myself.

Thanks for the help.